Files
jacuzzi/frontend/js/sqlite.js
Simon c54d0889c1 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
2026-09-06 08:32:44 +00:00

320 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.App = window.App || {};
App.sqlite = App.sqlite || {};
// A read-only SQLite file reader, just big enough to walk a table and hand back
// its rows. It exists so a Hot Tub backup can be opened in the browser with no
// dependency and no upload: the file stays on the device, and a 12MB database
// costs one ArrayBuffer.
//
// What it covers: the file header, the schema table, table b-trees (interior and
// leaf), record decoding, and payloads that spill onto overflow pages -- which
// real rows here do. What it deliberately doesn't: writes, indexes, WITHOUT
// ROWID tables, encryption, and a WAL sidecar (an exported backup is a single
// checkpointed file; see readTable's error for the case that isn't true).
//
// Reference: https://www.sqlite.org/fileformat2.html
(function() {
const HEADER_MAGIC = 'SQLite format 3';
const PAGE_INTERIOR_INDEX = 0x02;
const PAGE_INTERIOR_TABLE = 0x05;
const PAGE_LEAF_INDEX = 0x0a;
const PAGE_LEAF_TABLE = 0x0d;
// Serial types 1-6 are big-endian two's complement integers of these widths.
const INT_WIDTHS = { 1: 1, 2: 2, 3: 3, 4: 4, 5: 6, 6: 8 };
const utf8 = new TextDecoder('utf-8');
const utf16le = new TextDecoder('utf-16le');
const utf16be = new TextDecoder('utf-16be');
// Variable-length integer: up to nine bytes, seven bits each, most
// significant first; a set high bit means "another byte follows". The ninth
// byte, if reached, contributes all eight of its bits.
function readVarint(bytes, offset) {
let value = 0;
for (let i = 0; i < 8; i++) {
const byte = bytes[offset + i];
if (byte === undefined) throw new Error('sqlite: truncated varint');
if (i === 7) {
// Bits can exceed 2^53 here in principle; no field this reader
// looks at (payload sizes, rowids, serial types) comes close.
value = value * 128 + byte;
return [value, 8];
}
value = value * 128 + (byte & 0x7f);
if (!(byte & 0x80)) return [value, i + 1];
}
const last = bytes[offset + 8];
return [value * 256 + last, 9];
}
// Big-endian two's complement, `width` bytes wide. Eight-byte values go
// through BigInt so the sign is applied exactly, then come back as a Number
// when they fit in one (they always do for the fields read here).
function readBigEndianInt(bytes, offset, width) {
if (width === 8) {
let big = 0n;
for (let i = 0; i < 8; i++) big = (big << 8n) | BigInt(bytes[offset + i]);
if (big >= 0x8000000000000000n) big -= 0x10000000000000000n;
return (big >= BigInt(Number.MIN_SAFE_INTEGER) && big <= BigInt(Number.MAX_SAFE_INTEGER))
? Number(big) : big;
}
let value = 0;
for (let i = 0; i < width; i++) value = value * 256 + bytes[offset + i];
if (bytes[offset] & 0x80) value -= Math.pow(2, width * 8);
return value;
}
function Database(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes.length < 100) throw new Error('sqlite: file is too small to be a database');
const magic = utf8.decode(bytes.subarray(0, 16));
if (magic !== HEADER_MAGIC) throw new Error('sqlite: not a SQLite database');
const view = new DataView(buffer);
let pageSize = view.getUint16(16);
if (pageSize === 1) pageSize = 65536;
const reserved = bytes[20];
// Bytes at the end of every page that the database reserves for itself
// (encryption extensions use these); everything below counts in
// "usable" space rather than page size.
const usable = pageSize - reserved;
const encoding = view.getUint32(56) || 1;
this.bytes = bytes;
this.view = view;
this.pageSize = pageSize;
this.usable = usable;
this.encoding = encoding;
this.pageCount = view.getUint32(28) || Math.floor(bytes.length / pageSize);
}
Database.prototype.decodeText = function(slice) {
if (this.encoding === 2) return utf16le.decode(slice);
if (this.encoding === 3) return utf16be.decode(slice);
return utf8.decode(slice);
};
Database.prototype.pageOffset = function(pageNumber) {
const offset = (pageNumber - 1) * this.pageSize;
if (offset < 0 || offset + this.pageSize > this.bytes.length) {
throw new Error(`sqlite: page ${pageNumber} is outside the file`);
}
return offset;
};
// Reassembles one cell's payload, following the overflow chain when the row
// is too big to sit in a single page. The split point isn't "whatever fits"
// -- SQLite picks it so that overflow pages stay well filled; the formulas
// below are from the file-format spec and must match exactly, or every byte
// after the split is misread.
Database.prototype.readPayload = function(pageStart, localOffset, payloadSize) {
const usable = this.usable;
const maxLocal = usable - 35;
let localSize = payloadSize;
if (payloadSize > maxLocal) {
const minLocal = Math.floor(((usable - 12) * 32) / 255) - 23;
const surplus = minLocal + ((payloadSize - minLocal) % (usable - 4));
localSize = surplus <= maxLocal ? surplus : minLocal;
}
const payload = new Uint8Array(payloadSize);
payload.set(this.bytes.subarray(pageStart + localOffset, pageStart + localOffset + localSize));
if (localSize === payloadSize) return payload;
let written = localSize;
let nextPage = this.view.getUint32(pageStart + localOffset + localSize);
const seen = new Set();
while (nextPage && written < payloadSize) {
if (seen.has(nextPage)) throw new Error('sqlite: overflow chain loops');
seen.add(nextPage);
const offset = this.pageOffset(nextPage);
const take = Math.min(usable - 4, payloadSize - written);
payload.set(this.bytes.subarray(offset + 4, offset + 4 + take), written);
written += take;
nextPage = this.view.getUint32(offset);
}
if (written < payloadSize) throw new Error('sqlite: overflow chain ended early');
return payload;
};
// Splits a record into values. `wanted` (a Set of column indexes) keeps this
// from decoding columns nobody asked for -- a row here carries a couple of
// kilobytes of format JSON that an import has no use for.
Database.prototype.decodeRecord = function(payload, wanted) {
const [headerSize, headerVarintLength] = readVarint(payload, 0);
const serialTypes = [];
let cursor = headerVarintLength;
while (cursor < headerSize) {
const [serialType, length] = readVarint(payload, cursor);
serialTypes.push(serialType);
cursor += length;
}
const values = new Array(serialTypes.length).fill(null);
let bodyOffset = headerSize;
for (let i = 0; i < serialTypes.length; i++) {
const serialType = serialTypes[i];
let size = 0;
if (serialType >= 12) size = Math.floor((serialType - (serialType % 2 === 0 ? 12 : 13)) / 2);
else if (INT_WIDTHS[serialType]) size = INT_WIDTHS[serialType];
else if (serialType === 7) size = 8;
if (!wanted || wanted.has(i)) {
if (serialType === 0) values[i] = null;
else if (INT_WIDTHS[serialType]) values[i] = readBigEndianInt(payload, bodyOffset, INT_WIDTHS[serialType]);
else if (serialType === 7) values[i] = new DataView(payload.buffer, payload.byteOffset + bodyOffset, 8).getFloat64(0);
else if (serialType === 8) values[i] = 0;
else if (serialType === 9) values[i] = 1;
else if (serialType >= 12 && serialType % 2 === 0) values[i] = payload.slice(bodyOffset, bodyOffset + size);
else if (serialType >= 13) values[i] = this.decodeText(payload.subarray(bodyOffset, bodyOffset + size));
}
bodyOffset += size;
}
return values;
};
// Depth-first walk of a table b-tree, calling onRow(values, rowid) per row.
Database.prototype.walkTable = function(rootPage, wanted, onRow) {
const stack = [rootPage];
const seen = new Set();
while (stack.length) {
const pageNumber = stack.pop();
if (!pageNumber || seen.has(pageNumber)) continue;
seen.add(pageNumber);
const pageStart = this.pageOffset(pageNumber);
// Page 1 carries the 100-byte file header before its b-tree header.
const headerStart = pageNumber === 1 ? pageStart + 100 : pageStart;
const pageType = this.bytes[headerStart];
if (pageType === PAGE_INTERIOR_INDEX || pageType === PAGE_LEAF_INDEX) {
throw new Error('sqlite: WITHOUT ROWID tables are not supported');
}
if (pageType !== PAGE_INTERIOR_TABLE && pageType !== PAGE_LEAF_TABLE) {
throw new Error(`sqlite: unexpected page type ${pageType} on page ${pageNumber}`);
}
const cellCount = this.view.getUint16(headerStart + 3);
const isInterior = pageType === PAGE_INTERIOR_TABLE;
const cellPointers = headerStart + (isInterior ? 12 : 8);
if (isInterior) {
stack.push(this.view.getUint32(headerStart + 8)); // right-most child
}
for (let i = 0; i < cellCount; i++) {
const cellOffset = pageStart + this.view.getUint16(cellPointers + i * 2);
if (isInterior) {
stack.push(this.view.getUint32(cellOffset));
continue;
}
const [payloadSize, sizeLength] = readVarint(this.bytes, cellOffset);
const [rowid, rowidLength] = readVarint(this.bytes, cellOffset + sizeLength);
const payload = this.readPayload(pageStart, cellOffset - pageStart + sizeLength + rowidLength, payloadSize);
onRow(this.decodeRecord(payload, wanted), rowid);
}
}
};
// The schema lives in a table b-tree rooted at page 1: one row per table,
// index, view and trigger, with the columns below.
const MASTER_COLUMNS = ['type', 'name', 'tbl_name', 'rootpage', 'sql'];
Database.prototype.schema = function() {
if (this._schema) return this._schema;
const entries = [];
this.walkTable(1, null, (values) => {
const entry = {};
MASTER_COLUMNS.forEach((name, i) => { entry[name] = values[i]; });
entries.push(entry);
});
this._schema = entries;
return entries;
};
Database.prototype.tableNames = function() {
return this.schema().filter((entry) => entry.type === 'table').map((entry) => entry.name);
};
// Column names in declaration order, read out of the CREATE TABLE text --
// the file format itself doesn't store them anywhere else. Table-level
// constraints look like columns to a naive split, so they're skipped by
// keyword; the same walk notes an INTEGER PRIMARY KEY, whose value lives in
// the rowid rather than in the record.
const TABLE_CONSTRAINT_RE = /^(primary|unique|check|foreign|constraint)\b/i;
function parseColumns(sql) {
const open = sql.indexOf('(');
const close = sql.lastIndexOf(')');
if (open < 0 || close < open) return { names: [], rowidAlias: -1 };
const body = sql.slice(open + 1, close);
const parts = [];
let depth = 0;
let current = '';
let quote = '';
for (const char of body) {
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'" || char === '`') { quote = char; current += char; continue; }
if (char === '(') depth++;
if (char === ')') depth--;
if (char === ',' && depth === 0) { parts.push(current); current = ''; continue; }
current += char;
}
parts.push(current);
const names = [];
let rowidAlias = -1;
parts.forEach((part) => {
const definition = part.trim();
if (!definition || TABLE_CONSTRAINT_RE.test(definition)) return;
const match = definition.match(/^(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*))/);
if (!match) return;
const name = match[1] || match[2] || match[3] || match[4];
if (/\binteger\s+primary\s+key\b/i.test(definition)) rowidAlias = names.length;
names.push(name);
});
return { names, rowidAlias };
}
// Reads every row of `tableName` as an object. `columns`, when given, limits
// both what is decoded and what each object carries.
Database.prototype.readTable = function(tableName, options) {
const wantedColumns = options && options.columns;
const entry = this.schema().find((row) => row.type === 'table' && row.name === tableName);
if (!entry) throw new Error(`sqlite: no table named ${tableName}`);
if (!entry.rootpage) throw new Error(`sqlite: table ${tableName} has no data pages`);
const { names, rowidAlias } = parseColumns(entry.sql || '');
if (!names.length) throw new Error(`sqlite: could not read the columns of ${tableName}`);
let indexes = names.map((_, i) => i);
if (Array.isArray(wantedColumns) && wantedColumns.length) {
indexes = wantedColumns.map((name) => {
const index = names.indexOf(name);
if (index < 0) throw new Error(`sqlite: ${tableName} has no column ${name}`);
return index;
});
}
const wanted = new Set(indexes);
const rows = [];
this.walkTable(entry.rootpage, wanted, (values, rowid) => {
const row = {};
indexes.forEach((index) => {
row[names[index]] = (index === rowidAlias && values[index] === null) ? rowid : values[index];
});
rows.push(row);
});
return rows;
};
App.sqlite.open = function(buffer) {
return new Database(buffer);
};
})();