Import favorites from a Hot Tub backup

Settings gains a file picker that reads an exported Hot Tub database and
merges its favorites into this client's. The file never leaves the device:
sqlite.js is a small read-only reader -- header, schema, table b-trees,
record decoding, and the overflow pages that real rows here spill onto --
which is all it takes to walk one table, and avoids putting a wasm SQLite
behind a CDN fetch.

The two sides don't agree on what identifies a video. The app keys one by a
hash it computes locally (a 64-hex string); the server, and so this client,
keys it as something like "reddit-1rdudss". So the merge matches on
normalized URL: entries already saved here are left exactly as they are,
keeping the server id that makes a listing card's heart light up, and only
genuinely new videos are appended.

That means an imported favorite has no server id, so hearts now also match
by URL (`data-fav-url` on the card, feed slide and favorites bar). Without
it an imported favorite would look unsaved on its own card, and clicking
the heart would file a second copy of the same video.

Only the columns a favorite needs are read. `allFormats` is deliberately
left behind: it holds resolved, signed URLs, which is exactly what
favorites must not store (they expire -- see App.favorites.normalize).

Tests (scratchpad): the reader checked against Python's sqlite3 on a real
12MB backup -- table list, every table's row count, all 515 favorites with
their fields and order, and the 25 longest records byte-for-byte, which is
where a wrong overflow split shows up; and the Settings control driven
end-to-end, covering the merge, an existing favorite keeping its id, a
re-import adding nothing, and a listing card recognising an imported
favorite and unfavoriting it cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-06 08:32:44 +00:00
parent b48d7aa161
commit c54d0889c1
8 changed files with 226 additions and 7 deletions

View File

@@ -63,10 +63,90 @@ App.favorites = App.favorites || {};
};
};
// Identity across sources. Favorites added here are keyed by the server's
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
// keys videos by a hash of its own). Comparing normalized URLs is what
// stops the same video being listed twice under two different keys.
App.favorites.urlKey = function(url) {
const raw = String(url || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw, window.location.href);
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname.replace(/\/+$/, '');
return `${host}${path}${parsed.search}`;
} catch (err) {
return raw.toLowerCase();
}
};
// Adds favorites from an import, skipping any this client already has.
// Existing entries are left exactly as they are -- they carry the server id
// that makes a listing card's heart light up, which an imported entry has
// no way to know -- and new ones are appended after them.
App.favorites.mergeImported = function(entries) {
const incoming = Array.isArray(entries) ? entries : [];
const favorites = App.favorites.getAll();
const keys = new Set();
const urls = new Set();
favorites.forEach((item) => {
if (!item) return;
if (item.key) keys.add(item.key);
const urlKey = App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
let added = 0;
let skipped = 0;
incoming.forEach((entry) => {
if (!entry || !entry.key) return;
const urlKey = App.favorites.urlKey(entry.url);
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
skipped++;
return;
}
keys.add(entry.key);
if (urlKey) urls.add(urlKey);
favorites.push(entry);
added++;
});
if (added) {
App.favorites.setAll(favorites);
App.favorites.renderBar();
App.favorites.syncButtons();
}
return { added, skipped, total: favorites.length };
};
App.favorites.getSet = function() {
return new Set(App.favorites.getAll().map((item) => item.key));
};
// Same set, addressed by URL. Imported favorites are keyed by URL rather
// than by a server id, so a listing card can only recognise one this way.
App.favorites.getUrlSet = function() {
const urls = new Set();
App.favorites.getAll().forEach((item) => {
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
return urls;
};
// Is this video already a favorite, whichever way it got saved? Checked by
// key first, then by URL, so a card and an imported entry for the same
// video are recognised as one thing.
App.favorites.indexOfEntry = function(favorites, video) {
const key = App.favorites.getKey(video);
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
if (byKey >= 0) return byKey;
const meta = (video && video.meta) || video || {};
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
if (!urlKey) return -1;
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
};
App.favorites.isVisible = function() {
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
};
@@ -85,10 +165,12 @@ App.favorites = App.favorites || {};
App.favorites.syncButtons = function() {
const favoritesSet = App.favorites.getSet();
const favoriteUrls = App.favorites.getUrlSet();
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
const key = button.dataset.favKey;
if (!key) return;
App.favorites.setButtonState(button, favoritesSet.has(key));
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
if (!key && !urlKey) return;
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
});
};
@@ -96,7 +178,9 @@ App.favorites = App.favorites || {};
const key = App.favorites.getKey(video);
if (!key) return;
const favorites = App.favorites.getAll();
const existingIndex = favorites.findIndex((item) => item.key === key);
// By key or by URL: unfavoriting a card whose video came in from a
// backup must remove that entry, not add a second one beside it.
const existingIndex = App.favorites.indexOfEntry(favorites, video);
const becameFavorite = existingIndex < 0;
if (existingIndex >= 0) {
favorites.splice(existingIndex, 1);
@@ -140,7 +224,7 @@ App.favorites = App.favorites || {};
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>