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:
69
frontend/js/hottubBackup.js
Normal file
69
frontend/js/hottubBackup.js
Normal file
@@ -0,0 +1,69 @@
|
||||
window.App = window.App || {};
|
||||
App.hottubBackup = App.hottubBackup || {};
|
||||
|
||||
// Reads a Hot Tub app backup (an exported SQLite database) and turns the videos
|
||||
// it has flagged as favorites into this client's favorites.
|
||||
//
|
||||
// The app and this client don't agree on identity: the app keys a video by a
|
||||
// hash it computes locally, while the server -- and so this client -- keys it by
|
||||
// something like "reddit-1rdudss". So an imported favorite is matched to an
|
||||
// existing one by URL, and carries no id of its own; see App.favorites.mergeImported.
|
||||
(function() {
|
||||
// The app stores a comma-separated set here ("favorite", "recent", ...).
|
||||
// It also keeps a `favoriteDate` on rows it no longer flags -- a leftover
|
||||
// from unfavoriting -- so the flag, not the date, is what counts.
|
||||
const FAVORITE_FLAG = 'favorite';
|
||||
|
||||
// Only what a favorite needs. Skipping the rest matters: `allFormats` alone
|
||||
// is kilobytes of resolved-format JSON per row, and it is exactly the kind
|
||||
// of thing this client must not store -- those URLs are signed and expire
|
||||
// (see App.favorites.normalize).
|
||||
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
|
||||
|
||||
const hasFavoriteFlag = function(flags) {
|
||||
if (!flags) return false;
|
||||
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
|
||||
};
|
||||
|
||||
// Newest first, matching how favorites are ordered when added by hand.
|
||||
const byNewest = function(a, b) {
|
||||
return String(b.favoriteDate || '').localeCompare(String(a.favoriteDate || ''));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFavorites = function(buffer) {
|
||||
const db = App.sqlite.open(buffer);
|
||||
if (db.tableNames().indexOf('video_details') < 0) {
|
||||
throw new Error('This database has no video_details table -- is it a Hot Tub backup?');
|
||||
}
|
||||
const rows = db.readTable('video_details', { columns: COLUMNS });
|
||||
return rows
|
||||
.filter((row) => row.url && hasFavoriteFlag(row.flags))
|
||||
.sort(byNewest)
|
||||
.map((row) => ({
|
||||
// No id: the app's own is meaningless to this client, and the
|
||||
// URL is what both sides agree on.
|
||||
key: row.url,
|
||||
id: null,
|
||||
url: row.url,
|
||||
title: row.title || '',
|
||||
thumb: row.thumb || '',
|
||||
channel: '',
|
||||
uploader: row.uploader || '',
|
||||
duration: Number(row.duration) || 0,
|
||||
isLive: false
|
||||
}));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFile = function(file) {
|
||||
return file.arrayBuffer().then((buffer) => App.hottubBackup.readFavorites(buffer));
|
||||
};
|
||||
|
||||
// Reads the file and merges what it finds. Resolves to the merge summary
|
||||
// ({found, added, skipped, total}) so the caller can report it.
|
||||
App.hottubBackup.importFile = function(file) {
|
||||
return App.hottubBackup.readFile(file).then((entries) => {
|
||||
const result = App.favorites.mergeImported(entries);
|
||||
return Object.assign({ found: entries.length }, result);
|
||||
});
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user