PageSourceSearch

https://pinako.pro/portal/js/data.js?v=3a3e7c85

js pinako.pro collected 2026-10-02 02:06:50 UTC 167,987 bytes, 3,696 lines download raw bytes

1// Supabase data layer
2console.log('[Portal data.js] v3 loaded — multi-device');
3import { supabase, restUpsertKeepalive, SUPABASE_URL, SUPABASE_ANON_KEY } from './supabase-config.js';
4import { enqueueOfflineWrite, isNetworkError } from './offline-queue.js';
5import { readFlagEntries, writeFlagEntriesInto } from './flag-backend.js';
6
7// State
8export let treeData = [];
9export let libraryData = [];
10export let globalNotes = [];
11export let treeVersion = 0;
12
13export function setTreeData(data) { treeData = data; }
14export function setLibraryData(data) { libraryData = data; }
15export function setGlobalNotes(data) { globalNotes = data; }
16
17// ─── Edge Function auth headers ─────────────────────────
18// supabase.functions.invoke() does not reliably forward the
19// user's session JWT to the Supabase gateway, so functions with
20// verify_jwt = true (claim-subscription, create-portal, send-feedback,
21// send-invite) return 401 at the edge before their code runs.
22// Functions with verify_jwt = false also need it, because they
23// read the auth header inside the function to identify the user.
24async function authHeaders() {
25    const { data: { session } } = await supabase.auth.getSession();
26    if (!session?.access_token) return undefined;
27    return { Authorization: `Bearer ${session.access_token}` };
28}
29
30// ─── Paginated fetch ────────────────────────────────────
31// Supabase PostgREST returns at most 1000 rows per request.
32// Accepts a factory function that builds a fresh query each page
33// (query builders may not be safely reusable after await). The factory
34// receives select options and must forward them (`(sel) => ...select('*', sel)`)
35// so the exact row count rides along with page 1.
36// An expired session makes RLS return empty pages with NO error, so a JWT
37// dying between pages would otherwise truncate the result silently — and a
38// truncated tree fed into a set-difference save deletes every row the read
39// missed. A short read is therefore a throw, never end-of-data.
40const PAGE_SIZE = 1000;
41async function fetchAllRows(buildQuery) {
42    let allData = [];
43    let from = 0;
44    let expected = null;
45    while (true) {
46        const { data, count, error } = await buildQuery({ count: 'exact' }).range(from, from + PAGE_SIZE - 1);
47        if (error) throw error;
48        if (expected === null && typeof count === 'number') expected = count;
49        if (!data || data.length === 0) break;
50        allData = allData.concat(data);
51        if (data.length < PAGE_SIZE) break;
52        from += PAGE_SIZE;
53    }
54    if (expected !== null && allData.length < expected) {
55        throw new Error(`fetchAllRows: read ${allData.length} of ${expected} rows — truncated read`);
56    }
57    return allData;
58}
59
60// ─── Phase 3 (Yjs) entity-existence order ────────────────────
61// The extension's entity-existence Y.Doc became authoritative for library
62// and group ORDER at the S3 cutover (phase3-yjs-design.md in the extension
63// repo). The display_order columns stop moving on reorders from cutover-era
64// extensions, so the portal reads order from the doc and falls back to
65// display_order for accounts that haven't migrated yet (no doc row, or an
66// empty doc). Existence stays table-driven: content rows are still
67// created/deleted by the owning device, and membership (library_ids) +
68// library_panel_order are still dual-written.
69let _entityLibPos = null;    // Map<libraryId, position>, null = no doc loaded
70let _entityGroupPos = null;
71let _entityOrderPromise = null;
72let _entityOrderUid = null;
73
74function _b64ToBytes(b64) {
75    const bin = atob(b64);
76    const bytes = new Uint8Array(bin.length);
77    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
78    return bytes;
79}
80
81async function _loadEntityOrder(uid) {
82    _entityLibPos = null;
83    _entityGroupPos = null;
84    try {
85        const Y = window.Tiptap?.Y;
86        if (!Y) return;
87        // #23: distinguish "no row" (PGRST116 → legitimate fallback to
88        // display_order) from a real/transient error. On a real error, retry
89        // once before falling back, so a blip doesn't freeze the order at the
90        // stale display_order for the whole session.
91        let data = null, error = null;
92        for (let attempt = 0; attempt < 2; attempt++) {
93            ({ data, error } = await supabase
94                .from('user_yjs_docs')
95                .select('state')
96                .eq('user_id', uid)
97                .eq('doc_name', 'entity-existence')
98                .single());
99            if (!error || error.code === 'PGRST116') break; // success or genuine no-row
100            if (attempt === 0) await new Promise(r => setTimeout(r, 600)); // transient → retry once
101        }
102        if (error && error.code !== 'PGRST116') {
103            console.warn('[Portal entityOrder] fetch error after retry, using display_order:', error.message || error.code);
104            return;
105        }
106        if (!data?.state) return; // no doc → pre-migration account (legitimate fallback)
107        const doc = new Y.Doc();
108        Y.applyUpdate(doc, _b64ToBytes(data.state));
109        // First occurrence wins on duplicates (same dedupe rule as the
110        // extension's normalize pass).
111        const seenL = new Set(), seenG = new Set();
112        const libIds = doc.getArray('libraries').toArray()
113            .map(e => e && e.id).filter(id => id && !seenL.has(id) && seenL.add(id));
114        const groupIds = doc.getArray('library_groups').toArray()
115            .map(e => e && e.id).filter(id => id && !seenG.has(id) && seenG.add(id));
116        doc.destroy();
117        if (libIds.length === 0 && groupIds.length === 0) return; // empty doc → fallback
118        _entityLibPos = new Map(libIds.map((id, i) => [id, i]));
119        _entityGroupPos = new Map(groupIds.map((id, i) => [id, i]));
120        console.log(`[Portal entityOrder] doc order loaded: ${libIds.length} libraries, ${groupIds.length} groups`);
121    } catch (e) {
122        console.warn('[Portal entityOrder] load failed, using display_order:', e?.message || String(e));
123        _entityLibPos = null;
124        _entityGroupPos = null;
125    }
126}
127
128// One doc fetch per session per user (mirrors the session-cache pattern of
129// the loaders below); a page reload re-reads naturally.
130function ensureEntityOrder(uid) {
131    if (_entityOrderUid !== uid || !_entityOrderPromise) {
132        _entityOrderUid = uid;
133        _entityOrderPromise = _loadEntityOrder(uid);
134    }
135    return _entityOrderPromise;
136}
137
138// Owned libraries in doc order when present (doc-known first, display_order
139// stragglers after), linked refs last. Exactly the legacy comparator when no
140// doc is loaded.
141function compareLibraryOrder(a, b) {
142    if (a?._linkedRef && !b?._linkedRef) return 1;
143    if (!a?._linkedRef && b?._linkedRef) return -1;
144    if (_entityLibPos) {
145        const pa = _entityLibPos.has(a?.id) ? _entityLibPos.get(a.id) : null;
146        const pb = _entityLibPos.has(b?.id) ? _entityLibPos.get(b.id) : null;
147        if (pa !== null && pb !== null) return pa - pb;
148        if (pa !== null) return -1;
149        if (pb !== null) return 1;
150    }
151    return (a?.display_order ?? Infinity) - (b?.display_order ?? Infinity);
152}
153
154function _sortGroupsByEntityOrder(groups) {
155    if (!_entityGroupPos || !Array.isArray(groups)) return groups;
156    // Stable sort: non-doc groups keep their display_order fetch order.
157    return groups.sort((a, b) =>
158        (_entityGroupPos.has(a?.id) ? _entityGroupPos.get(a.id) : Infinity)
159        - (_entityGroupPos.has(b?.id) ? _entityGroupPos.get(b.id) : Infinity));
160}
161
162// ─── 2A: portal reorder → entity-doc write ──────────────────
163// Under cutover the entity doc is the ORDER authority (display_order is
164// vestigial). So a portal reorder must write the doc, not just display_order /
165// library_panel_order, or it reverts on reload (#13). Stateless read-modify-
166// write of the user_yjs_docs blob (no persistent provider), plus a broadcast
167// on the same channel the extension's provider listens to, for live
168// convergence (#12). Last-writer-wins on the blob is acceptable single-user:
169// both surfaces broadcast deltas, so live docs still converge; a portal
170// reorder is a rare, user-initiated action.
171function _bytesToB64(bytes) {
172    let bin = '';
173    const C = 8192;
174    for (let i = 0; i < bytes.length; i += C) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + C));
175    return btoa(bin);
176}
177
178// delete+reinsert reorder, filtering targets absent from the array (mirrors
179// the extension's _entityAlignOrder incl. its #19 cursor-desync fix).
180function _portalAlign(arr, targetKeys, keyOf) {
181    const present = new Set(arr.toArray().map(keyOf));
182    const target = targetKeys.filter(k => present.has(k));
183    for (let p = 0; p < target.length && p < arr.length; p++) {
184        if (keyOf(arr.get(p)) === target[p]) continue;
185        let j = -1;
186        for (let k = p + 1; k < arr.length; k++) { if (keyOf(arr.get(k)) === target[p]) { j = k; break; } }
187        if (j === -1) continue;
188        const entry = arr.get(j);
189        arr.delete(j, 1);
190        arr.insert(p, [entry]);
191    }
192}
193
194// The entity channel the extension's doc provider listens and speaks on. ONE per
195// signed-in account: the order writer below sends on it, and the flag labels'
196// reader (S-META, further down) listens on it, so its handlers are bound before
197// the subscribe. Only the flag load, which runs for the account signed in now,
198// may move it to another account (bind); a write still finishing for an account
199// that has since signed out gets null and skips its broadcast, rather than
200// pulling the channel back to the old account.
201let _entityBcastChannel = null;
202let _entityBcastUid = null;
203function _ensureEntityBcastChannel(uid, { bind = false } = {}) {
204    if (_entityBcastChannel && _entityBcastUid === uid) return _entityBcastChannel;
205    if (_entityBcastChannel && !bind) return null;
206    if (_entityBcastChannel) {
207        try { supabase.removeChannel(_entityBcastChannel); } catch (_) {}
208    }
209    _entityBcastUid = uid;
210    _entityBcastChannel = supabase.channel(`yjs-user-${uid}-entity-existence`);
211    _entityBcastChannel
212        .on('broadcast', { event: 'sync' }, ({ payload }) => _onEntityBroadcastUpdate(uid, payload))
213        .on('broadcast', { event: 'resync-needed' }, () => _refetchEntityMeta(uid))
214        .subscribe((status) => { if (status === 'SUBSCRIBED') _requestEntitySync(uid); });
215    return _entityBcastChannel;
216}
217
218// Every portal write of the entity blob is a read-modify-write of the WHOLE blob,
219// so two of them interleaving (a reorder's order write and a flag label's) would
220// let the later upsert drop the earlier one's change. One chain for both.
221let _entityDocWriteChain = Promise.resolve();
222function _serialEntityDocWrite(fn) {
223    const run = _entityDocWriteChain.then(fn, fn);
224    _entityDocWriteChain = run.then(() => undefined, () => undefined);
225    return run;
226}
227
228let _entityDocWriteTimer = null;
229function _scheduleEntityDocWrite(uid) {
230    if (!uid) return;
231    clearTimeout(_entityDocWriteTimer);
232    _entityDocWriteTimer = setTimeout(() => {
233        _serialEntityDocWrite(() =>
233 _writeEntityOrderToDoc(uid))
234            .catch(e => console.warn('[Portal entityOrder] doc write failed:', e?.message || String(e)));
235    }, 400); // coalesce a reorder's saveLibraryOrder + saveLibraryPanelOrder into one write
236}
237
238async function _writeEntityOrderToDoc(uid) {
239    const Y = window.Tiptap?.Y;
240    if (!Y) return;
241    // RMW the current blob so we layer the reorder on top of the latest state.
242    const { data: row, error } = await supabase
243        .from('user_yjs_docs').select('state')
244        .eq('user_id', uid).eq('doc_name', 'entity-existence').single();
245    if (error && error.code !== 'PGRST116') return; // transient → skip; extension reconciles later
246    const doc = new Y.Doc();
247    if (row?.state) Y.applyUpdate(doc, _b64ToBytes(row.state));
248    const libOrder = libraryData.filter(f => f && !f._linkedRef && f.id).map(f => f.id);
249    const grpOrder = libraryGroupData.filter(g => g && g.id).map(g => g.id);
250    const panelKeys = (libraryPanelOrder || []).filter(e => e && e.id).map(e => `${e.type}:${e.id}`);
251    // Reorder only — never add/remove existence (that stays the extension's job).
252    _portalAlign(doc.getArray('libraries'), libOrder, e => e && e.id);
253    _portalAlign(doc.getArray('library_groups'), grpOrder, e => e && e.id);
254    // Only an order this page read (see _panelOrderKnown): a default one would reorder the doc.
255    if (_panelOrderKnown) _portalAlign(doc.getArray('panel_order'), panelKeys, e => e && `${e.type}:${e.id}`);
256    // Membership order (covers in-group reorder); write only changed keys.
257    const mem = doc.getMap('group_membership');
258    for (const g of libraryGroupData) {
259        if (!g || !g.id) continue;
260        const want = Array.isArray(g.libraryIds) ? g.libraryIds : [];
261        if (JSON.stringify(mem.get(g.id) || []) !== JSON.stringify(want)) mem.set(g.id, [...want]);
262    }
263    const b64 = _bytesToB64(Y.encodeStateAsUpdate(doc));
264    doc.destroy();
265    await supabase.from('user_yjs_docs').upsert(
266        { user_id: uid, doc_name: 'entity-existence', state: b64, last_modified: new Date().toISOString() },
267        { onConflict: 'user_id,doc_name' });
268    // Refresh the portal's own order cache so its immediate re-render is correct.
269    _entityLibPos = new Map(libOrder.map((id, i) => [id, i]));
270    _entityGroupPos = new Map(grpOrder.map((id, i) => [id, i]));
271    // Live convergence: hand the full state to any subscribed extension provider.
272    try { _ensureEntityBcastChannel(uid)?.send({ type: 'broadcast', event: 'sync', payload: { data: b64 } }); }
273    catch (e) { console.warn('[Portal entityOrder] broadcast failed:', e?.message || String(e)); }
274}
275
276// ─── S-META on the portal: the tab-world star-flag labels ──────────────────
277// The extension keeps small account-level tab-world records in the entity doc's
278// 'main_meta' Y.Map (pinako.js S-META): 'flags:<hexLower>' → {label, custom, ts},
279// merged per hex by ts, larger wins. The portal keeps no local copy of the
280// record: the doc IS its store (the shared flag-registry.js reads it through
281// flag-backend.js, U6 slice 8.3a, which also holds the doc reader and writer). It reads main_meta when the page loads
282// the registry and whenever a doc update arrives on the entity channel, and it
283// writes a user's edit into the doc the way the order writer above does (a
284// read-modify-write of the blob, then a broadcast), stamped by the extension's
285// in-session push rule: a doc stamp at or above ours is out-bumped by one, so a
286// device with a slow clock is never write-blocked.
287let _entityMetaDoc = null;       // the entity doc as fetched, plus every update since
288let _entityMetaUid = null;
289const _entityFlagListeners = new Set();
290
291// A real fetch error THROWS: the registry latches a failed read rather than
292// treating it as an empty record (flag-registry.js _flagLoad).
293async function _fetchEntityDocState(uid) {
294    const { data, error } = await supabase
295        .from('user_yjs_docs').select('state')
296        .eq('user_id', uid).eq('doc_name', 'entity-existence').single();
297    if (error && error.code !== 'PGRST116') throw new Error(error.message || error.code || 'entity doc fetch failed');
298    return data?.state || null;
299}
300
301function _entityMetaDocFor(uid) {
302    const Y = window.Tiptap?.Y;
303    if (!Y) throw new Error('Yjs is not loaded on this page');
304    if (_entityMetaDoc && _entityMetaUid === uid) return _entityMetaDoc;
305    if (_entityMetaDoc) { try { _entityMetaDoc.destroy(); } catch (_) {} }
306    _entityMetaDoc = new Y.Doc();
307    _entityMetaUid = uid;
308    // Every change but the load's is news (the load hands its entries to the
309    // registry itself): a broadcast, a refetch, and this page's own write, whose
310    // fetched blob can carry another device's newer entries.
311    _entityMetaDoc.getMap('main_meta').observe((event) => {
312        if (event.transaction.origin === 'portal-load') return;
313        _notifyEntityFlagListeners(uid);
314    });
315    return _entityMetaDoc;
316}
317
318function _notifyEntityFlagListeners(uid) {
319    if (!_entityMetaDoc || _entityMetaUid !== uid) return;
320    const entries = readFlagEntries(_entityMetaDoc);
321    for (const fn of _entityFlagListeners) {
322        try { fn(uid, entries); } catch (e) { console.warn('[Portal flags] listener failed:', e?.message || String(e)); }
323    }
324}
325
326function _onEntityBroadcastUpdate(uid, payload) {
327    if (!_entityMetaDoc || _entityMetaUid !== uid || !payload || typeof payload.data !== 'string') return;
328    try { window.Tiptap.Y.applyUpdate(_entityMetaDoc, _b64ToBytes(payload.data), 'remote'); }
329    catch (e) { console.warn('[Portal flags] entity update failed to apply:', e?.message || String(e)); }
330}
331
332// A peer's update was too large to broadcast: read the stored blob instead.
333function _refetchEntityMeta(uid) {
334    if (!_entityMetaDoc || _entityMetaUid !== uid) return;
335    _fetchEntityDocState(uid).then((state) => {
336        if (state && _entityMetaDoc && _entityMetaUid === uid) window.Tiptap.Y.applyUpdate(_entityMetaDoc, _b64ToBytes(state), 'remote');
337    }).catch((e) => console.warn('[Portal flags] entity refetch failed:', e?.message || String(e)));
338}
339
340// Ask a live extension for whatever the stored blob lacks (its provider answers
341// a sync-request with the diff against our state vector).
342function _requestEntitySync(uid) {
343    if (!_entityMetaDoc || _entityMetaUid !== uid || !_entityBcastChannel) return;
344    try {
345        const sv = window.Tiptap.Y.encodeStateVector(_entityMetaDoc);
346        _entityBcastChannel.send({ type: 'broadcast', event: 'sync-request',
347            payload: { clientID: _entityMetaDoc.clientID, stateVector: _bytesToB64(sv) } });
348    } catch (e) { console.warn('[Portal flags] sync request failed:', e?.message || String(e)); }
349}
350
351/** The account's flag entries, read fresh from the stored doc; subscribes the page to later updates. */
352export async function loadEntityFlagEntries(uid) {
353    if (!uid) throw new Error('signed out');
354    const doc = _entityMetaDocFor(uid);
355    const state = await _fetchEntityDocState(uid);
356    if (_entityMetaDoc !== doc) throw new Error('the signed-in account changed while loading');
357    if (state) window.Tiptap.Y.applyUpdate(doc, _b64ToBytes(state), 'portal-load');
358    _ensureEntityBcastChannel(uid, { bind: true });
359    return readFlagEntries(doc);
360}
361
362/** cb(uid, entries) runs whenever another surface changes the flag entries. Returns the unsubscribe. */
363export function onEntityFlagEntriesChanged(cb) {
364    _entityFlagListeners.add(cb);
365    return () => _entityFlagListeners.delete(cb);
366}
367
368/**
369 * Write the given entries (Map<hexLower, {label, custom, ts}>) into the doc and
370 * broadcast. Returns Map<hex, the entry as written> (its ts possibly bumped).
371 * THROWS when the write did not land.
372 */
373export function writeEntityFlagEntries(uid, entries) {
374    return _serialEntityDocWrite(async () => {
375        const Y = window.Tiptap?.Y;
376        if (!Y) throw new Error('Yjs is not loaded on this page');
377        const state = await _fetchEntityDocState(uid);
378        const doc = new Y.Doc();
379        // The stored blob plus this write's own keys, and nothing else: merging the
380        // page's mirror in as well would upsert its unswept order history (the
381        // extension dedupes its arrays; this page does not). A change the extension
382        // has broadcast but not stored yet stays in its live doc, which merges this
383        // broadcast and stores its whole state again (the order writer's rule, 2A).
384        if (state) Y.applyUpdate(doc, _b64ToBytes(state));
385        const written = writeFlagEntriesInto(doc, entries);
386        if (written.size === 0) { doc.destroy(); return written; }
387        const update = Y.encodeStateAsUpdate(doc);
388        doc.destroy();
389        const b64 = _bytesToB64(update);
390        const { error } = await supabase.from('user_yjs_docs').upsert(
391            { user_id: uid, doc_name: 'entity-existence', state: b64, last_modified: new Date().toISOString() },
392            { onConflict: 'user_id,doc_name' });
393        if (error) throw new Error(error.message || error.code || 'entity doc write failed');
394        if (_entityMetaDoc && _entityMetaUid === uid) Y.applyUpdate(_entityMetaDoc, update, 'portal-write');
395        try { _ensureEntityBcastChannel(uid)?.send({ type: 'broadcast', event: 'sync', payload: { data: b64 } }); }
396        catch (e) { console.warn('[Portal flags] broadcast failed:', e?.message || String(e)); }
397        return written;
398    });
399}
400
401// ─── Session cache ──────────────────────────────────────
402// Avoids re-fetching from Supabase on every page refresh.
403// Real-time subscriptions keep data fresh between refreshes.
404// v2 (2026-07-26): cache-format version bump. The tabgroup color/snapshot
405// normalization lives in reconstructTree, which cache hits BYPASS — entries
406// written by a pre-normalization build carry tabgroup nodes with no `color`
407// key, and the post-fix writers would re-derive group_snapshot from that
408// shape as grey, permanently erasing the group's color in the cloud. A new
409// prefix orphans all pre-fix entries (sessionStorage, dies with the tab).
410// Bump again whenever reconstructTree's node shape changes.
411const CACHE_PREFIX = 'pinako_cache_v2_';
412
413function getCached(key) {
414    try {
415        const raw = sessionStorage.getItem(CACHE_PREFIX + key);
416        return raw ? JSON.parse(raw) : null;
417    } catch { return null; }
418}
419
420/** Returns true when the write actually landed. A quota failure leaves the
421 *  PREVIOUS value in storage, so a caller keeping a cache in step with a
422 *  cloud write must treat false as "the cache is now stale" — not ignore it. */
423function setCache(key, data) {
424    try {
425        sessionStorage.setItem(CACHE_PREFIX + key, JSON.stringify(data));
426        return true;
427    } catch { return false; /* quota exceeded */ }
428}
429
430/**
431 * Keep the libraries session cache in step with a write.
432 *
433 * `loadLibraries` serves the user's OWN libraries from `libraries_<uid>`
434 * whenever that key exists (linked libraries are always refetched). So a write
435 * that does not update the key is INVISIBLE after a reload in the same tab: the
436 * edit really is in the cloud, but the portal keeps showing pre-edit content
437 * until the tab is closed.
438 *
439 * That was a live bug, found 2026-08-23: copying into a library moved its card
440 * from "147 items" to "149 items", the rows were confirmed present in
441 * `library_nodes`, and a reload put the card back to 147. Clearing only this
442 * one key and reloading produced 149, which is what identified the cause.
443 * `saveLibrary` and `deleteLibrary` have many call sites across app.js and
444 * library-app.js, so the upkeep belongs here rather than at each of them.
445 *
446 * Mirrors the idiom the realtime handlers already use: rewrite from the live
447 * `libraryData`, minus linked refs, since `loadLibraries` appends those
448 * separately and a cached copy would show up twice.
449 *
450 * A no-op when nothing is cached yet — the next load fetches fresh regardless.
451 *
452 * @param {string} uid
453 * @param {object|null} folder   the library just written, upserted into the
454 *   cache so a brand-new one saved before it joins `libraryData` is not lost
455 * @param {{removedId?: string}} [opts]  id to drop, for deletes
456 */
457function syncLibrariesCache(uid, folder, { removedId = null } = {}) {
458    try {
459        if (!uid) return;
460        // Presence only — parsing the whole cached payload just to test the key
461        // would block the main thread after every save for nothing.
462        if (sessionStorage.getItem(CACHE_PREFIX + 'libraries_' + uid) === null) return;
463        // `libraryData` is only populated by loadLibraries, which the standalone
464        // library page never runs — its saves would otherwise REWRITE the cache
465        // down to just the one open library and make every other library vanish
466        // from the portal for the rest of the tab session. When this module
467        // does not hold the owned set, dropping the cache (refetch on next
468        // load) is the only honest move.
469        if (!libraryData.some(f => f && !f._linkedRef)) {
470            invalidateLibrariesCache(uid);
471            return;
472        }
473        // REPLACE by id, never skip-if-present: `libraryData` can hold a STALE
474        // copy of the folder just written (the standalone page saves its own
475        // object after the copy-picker populated libraryData), and keeping the
476        // stale one would re-cache exactly the pre-edit content this helper
477        // exists to retire.
478        const isFreshCopy = (f) => folder && !folder._linkedRef && f.id === folder.id;
479        const owned = libraryData
480            .filter(f => f && !f._linkedRef && f.id !== removedId)
481            .map(f => isFreshCopy(f) ? folder : f);
482        if (folder && !folder._linkedRef && folder.id && folder.id !== removedId &&
483            !owned.some(f => f.id === folder.id)) {
484            owned.push(folder);
485        }
486        // A failed write leaves the PRE-EDIT snapshot in storage — the exact
487        // stale-reload bug this helper exists to prevent — so fall back to
488        // invalidating.
489        if (!setCache('libraries_' + uid, owned)) invalidateLibrariesCache(uid);
490    } catch (_) { /* cache upkeep must never be what breaks a save */ }
491}
492
493/**
494 * Drop one session-cache key so the next load refetches from the cloud.
495 *
496 * This is the write-blind-cache rule: every one of this module's
497 * `pinako_cache_v2_*` keys is read on load, so every LOCAL write to the
498 * backing data must either rewrite the key or drop it — otherwise the edit is
499 * in the cloud but the tab keeps showing pre-edit content on reload, which is
500 * exactly the "my changes vanished" bug the libraries cache shipped with
501 * (fixed 2026-08-23; the rest of the keys swept 2026-08-24). Small payloads
502 * write through with setCache and fall back to this on a failed write (a
503 * failed write leaves the PRE-edit snapshot in place, which is worse than no
504 * cache); large or alias-keyed payloads (the tree) just invalidate.
505 */
506function invalidateCache(key) {
507    try { sessionStorage.removeItem(CACHE_PREFIX + key); } catch (_) {}
508}
509
510/**
511 * Drop the libraries cache so the next load refetches from the cloud.
512 * Used when a write fails partway: what is actually stored is then unknown,
513 * and a fresh read is the only honest answer.
514 */
515function invalidateLibrariesCache(uid) {
516    if (uid) invalidateCache('libraries_' + uid);
517}
518
519// Device ID for conflict detection
520export function getDeviceId() {
521    let id = localStorage.getItem('pinako_device_id');
522    if (!id) {
523        id = 'portal-' + crypto.randomUUID();
524        localStorage.setItem('pinako_device_id', id);
525    }
526    return id;
527}
528
529// What this PAGE writes into every `last_modified_by`, and what every realtime
530// handler here skips as its own echo. Not the device id alone: that lives in
531// localStorage, so every portal tab in this browser shares it, and each tab
532// threw away every other tab's writes as its own echo (a colour set on
533// library.html never reached an open /portal/ tab, 2026-09-30). The device id
534// stays the prefix, and stays the identity wherever one is needed (device_id,
535// by_device, the edit log). Nothing reads last_modified_by except these echo
536// checks; the extension compares it with its own device id, never equal.
537let _pageWriterId = null;
538function getPageWriterId() {
539    if (!_pageWriterId) _pageWriterId = getDeviceId() + '#' + crypto.randomUUID().slice(0, 8);
540    return _pageWriterId;
541}
542
543// True only when the session is confirmed live AND signed in as `uid`. An
544// empty read is also what a dead session gets through RLS (and what a read for
545// the account this tab just left gets), so an empty answer may only replace
546// cached data when this says yes. Any doubt answers no.
547async function _sessionIsLiveFor(uid) {
548    try {
549        const { data, error } = await supabase.auth.getUser();
550        return !error && !!uid && data?.user?.id === uid;
551    } catch (_) { return false; }
552}
553
554// Two timestamps that name the same instant, whatever their spelling
555// ('...Z' written here, '...+00:00' read back from Postgres).
556function _sameTime(a, b) {
557    return a === b || (a != null && b != null && Date.parse(a) === Date.parse(b));
558}
559
560/**
561 * Read a SMALL cached payload network-first: the cloud answer wins, and the
562 * session cache only stands in when the read fails (offline) or comes back
563 * empty without a confirmed session. A cache served on load without asking
564 * the cloud kept whatever this tab last saw, reload after reload, until the
565 * tab closed (the libraries bug of 2026-09-30); for a payload this small the
566 * read costs no more than a freshness check would.
567 *
568 * @param {string} uid
569 * @param {string} key          cache key (without the prefix)
570 * @param {() => Promise<*>} fetchFresh  the cloud read; throws on a failed read
571 * @param {(v: *) => boolean} isEmpty
572 * @returns {Promise<*>} the fresh value (cached when non-empty, the key dropped
573 *   when empty), or the cached one
574 */
575async function _loadFreshOrCached(uid, key, fetchFresh, isEmpty) {
576    const cached = getCached(key);
577    let fresh;
578    try {
579        fresh = await fetchFresh();
580    } catch (err) {
581        if (cached === null) throw err;
582        console.warn('[Portal] ' + key.split('_' + uid)[0] + ' read failed, serving the session cache:', err?.message || String(err));
583        return cached;
584    }
585    if (isEmpty(fresh)) {
586        if (cached !== null && !isEmpty(cached) && !(await _sessionIsLiveFor(uid))) return cached;
587        invalidateCache(key);
588        return fresh;
589    }
590    if (!setCache(key, fresh)) invalidateCache(key);
591    return fresh;
592}
593
594// Dispatch a save-error event for the UI layer to toast. The helper re-throws
595// by convention at the call site; this only handles the notification side so
596// existing error propagation behavior is unchanged.
597//
598// EXPORTED (U5.5 session 4) as dispatchSaveError, so /portal/ai reports a
599// failed save through THIS channel rather than a second private one. Its own
600// project pushes are hand-rolled (a version-guarded UPDATE, which no generic
601// write helper here can express), but the FAILURE story must be single-sourced
602// or the site grows two spellings of "we could not save that" — and the AI
603// page's was a console.warn nobody sees. Additive: the private name still
604// works, so every existing caller is untouched.
605export function dispatchSaveError(err, context) { _dispatchSaveError(err, context); }
606function _dispatchSaveError(err, context) {
607    try {
608        console.warn(`[Portal save-error] ${context}:`, err?.message || err);
609        if (typeof window !== 'undefined') {
610            window.dispatchEvent(new CustomEvent('pinako:save-error', {
611                detail: { context, message: err?.message || String(err) }
612            }));
613        }
614    } catch (_) { /* never let the notifier crash the caller */ }
615}
616
617// Interim offline fallback (TODOS P1 "offline write queue parity with
618// extension"): mirror of the extension's writeWithOfflineFallback
619// (src/supabase-sync.js). On a network-class failure the write is queued for
620// replay and the caller gets null back — the edit is preserved, not lost.
621// Every other error keeps the existing throw path, so _dispatchSaveError
622// toasts still fire for genuine failures. Wrapped paths: saveTreeImmediate,
623// saveLibrary (incl. syncNotesArray), writeEditLog. `inKey` extends the
624// extension's delete shape for the portal's `.in('id', [...])` deletes.
625async function writeWithOfflineFallback(table, operation, data, matchKey, inKey) {
626    try {
627        let result;
628        if (operation === 'upsert') {
629            result = await supabase.from(table).upsert(data);
630        } else if (operation === 'insert') {
631            result = await supabase.from(table).insert(data);
632        } else if (operation === 'delete') {
633            if (!matchKey && !inKey) throw new Error('delete requires matchKey or inKey');
634            let q = supabase.from(table).delete();
635            if (matchKey) q = q.match(matchKey);
636            if (inKey) q = q.in(inKey.column, inKey.values);
637            result = await q;
638        }
639        if (result.error) throw result.error;
640        return result;
641    } catch (err) {
642        if (!navigator.onLine || isNetworkError(err)) {
643            const queued = await enqueueOfflineWrite({ table, operation, data, matchKey, inKey });
644            if (queued) {
645                console.warn(`[Portal Offline] Offline — queued ${operation} on ${table} for replay`);
646                return null;
647            }
648        }
649        throw err;
650    }
651}
652
653// Currently selected device ID for main tree display (null = most recent)
654let selectedDeviceId = null;
655export function setSelectedDeviceId(id) { selectedDeviceId = id; }
656export function getSelectedDeviceId() { return selectedDeviceId; }
657
658// --- TREE ---
659
660// List all devices for a user (for device selector dropdown)
661export async function listDevices(uid) {
662    return _loadFreshOrCached(uid, 'devices_' + uid, async () => {
663        const { data, error } = await supabase
664            .from('devices')
665            .select('*')
666            .eq('user_id', uid)
667            .order('created_at', { ascending: true });
668        if (error) throw error;
669        return data || [];
670    }, v => !Array.isArray(v) || v.length === 0);
671}
672
673export async function renameDevice(deviceId, uid, newName) {
674    const { error } = await supabase
675        .from('devices')
676        .update({ device_name: newName })
677        .eq('id', deviceId)
678        .eq('user_id', uid);
679    if (error) throw error;
680    invalidateCache('devices_' + uid);
681}
682
683export async function removeDevice(deviceId, uid) {
684    const { error } = await supabase
685        .from('devices')
686        .delete()
687        .eq('id', deviceId)
688        .eq('user_id', uid);
689    if (error) throw error;
690    // Invalidate session cache so listDevices re-fetches. The old inline
691    // removeItem here spelled the key without the versioned prefix
692    // ('pinako_cache_devices_' vs CACHE_PREFIX 'pinako_cache_v2_...'), so it
693    // had never actually removed anything.
694    invalidateCache('devices_' + uid);
695}
696
697// ── S19 D2 (leg 5b): the SYNCED source selection ────────────────────────────
698//
699// `ai_source_selection` is ONE ROW PER (account, COMPUTER, app) — the S7f
700// ai_session_notes shape applied to a checkbox — with row-grain LWW on `ts`.
701// The migration's own header is the contract; the two facts that decide the
702// code below are:
703//
704//   • `enabled` IS DISPLAY + INTENT, NEVER THE PERMISSION. The permission for
705//     a native host to read an app's session files off a disk lives in one
706//     machine-local file the AI Bridge keeps, and sync never writes it. A row
707//     arriving at a computer is an INTENT its popup executes through
708//     setSourceSetting — the one writer — or does not execute at all.
709//   • So a row this surface writes may only ever REDUCE privilege. A browser
710//     has no computer of its own, therefore no row it writes may carry
711//     `enabled: true` for any machine. `assertSourceSelectionGrant` is that
712//     rule, and it lives HERE, in the write path, so no caller can route
713//     around it (the extension's twin is session-sync.js `_selAssertGrant`).
714//
715// `machine_id === ''` is the account-wide WEBSITE list — a real key, not a
716// null. Web providers are vendor-synced and read entirely extension-side, so
717// they name no computer and both directions are legitimate there.
718
719// Is this a WEB provider? Answered from the shared catalog that the session
720// pages load as a classic script (session-brand-icons.js). Absent on portal
721// pages that never load it — and on those pages nothing calls this — so an
722// unanswerable question does NOT block the write: the load-bearing half of the
723// assertion below (never enabled:true for a machine) needs no catalog at all.
724function _selIsWebProvider(provider) {
725    try {
726        const B = (typeof window !== 'undefined') ? window.PinakoBrandIcons : null;
727        if (!B || typeof B.isWebProvider !== 'function') return null; // no catalog here
728        return B.isWebProvider(provider) === true;
729    } catch (_) { return null; }
730}
731
732/**
733 * The grant rule, asserted before every write. Throws rather than returning
734 * false: a refusal here is a bug upstream, not a condition to branch on.
735 *
736 *   machine_id === ''  → the account-wide website list. Both directions are
737 *                        allowed; a DESKTOP provider has no business there.
738 *   machine_id !== ''  → a computer. `enabled: true` is refused outright: only
739 *                        the computer a source names may turn it on, and this
740 *                        surface is never that computer.
741 */
742export function assertSourceSelectionGrant(machineId, provider, enabled) {
743    if (typeof provider !== 'string' || !provider) {
744        throw new Error('writeSourceSelection: provider required');
745    }
746    if (machineId === '') {
747        if (_selIsWebProvider(provider) === false) {
748            throw new Error('writeSourceSelection: the account-wide source list is web-only (refused: ' + provider + ')');
749        }
750        return;
751    }
752    if (typeof machineId !== 'string' || !machineId) {
753        throw new Error('writeSourceSelection: machineId required');
754    }
755    if (enabled === true) {
756        throw new Error('writeSourceSelection: a source can only be enabled ON the computer it names (refused: '
757            + provider + ' for ' + machineId + ')');
758    }
759}
760
761/**
762 * Every selection row for this account. RLS already scopes the table; the
763 * explicit user_id filter is the PK prefix plus defence in depth, the same
764 * pairing the ai_session_notes pull uses.
765 *
766 * Cached in sessionStorage under the versioned prefix, like listDevices, and
767 * invalidated by every write below — a write path that left the cache stale is
768 * the library-cache bug this repo has already paid for once.
769 */
770export async function listSourceSelection(uid) {
771    if (!uid) return [];
772    const cached = getCached('src_sel_' + uid);
773    if (cached) return cached;
774    const { data, error } = await supabase
775        .from('ai_source_selection')
776        .select('machine_id, provider, enabled, requested, by_device, ts')
777        .eq('user_id', uid);
778    if (error) throw error;
779    const rows = data || [];
780    setCache('src_sel_' + uid, rows);
781    return rows;
782}
783
784export function invalidateSourceSelectionCache(uid) {
785    if (uid) invalidateCache('src_sel_' + uid);
786}
787
788/**
789 * Write ONE selection row, per-row LWW by `ts`.
790 *
791 * The engine is the extension's, per row: a conditional UPDATE guarded
792 * `.lt('ts', ts)` → 0 rows means nothing was beaten → INSERT → 23505 means a
793 * row pre-existed, so ONE settle UPDATE decides whether we beat it. A loss is
794 * reported (`landed:false`), never retried: the stamp is the moment of the
795 * click, so a retry would carry the same losing stamp forever.
796 *
797 * `by_device` is this browser's device id, which is what lets the computer
798 * being asked say WHO asked.
799 */
800export async function writeSourceSelection(uid, machineId, provider, patch) {
801    if (!uid) throw new Error('writeSourceSelection: signed-out');
802    const mid = (machineId === null || machineId === undefined) ? '' : String(machineId);
803    const enabled = !!(patch && patch.enabled === true);
804    const requested = !!(patch && patch.requested === true);
805    assertSourceSelectionGrant(mid, provider, enabled);
806    const ts = (patch && Number.isFinite(patch.ts)) ? patch.ts : Date.now();
807    const body = { enabled, requested, by_device: getDeviceId(), ts };
808    const where = (q) => q.eq('user_id', uid).eq('machine_id', mid).eq('provider', provider);
809
810    const { data, error } = await where(supabase.from('ai_source_selection').update(body))
811        .lt('ts', ts).select('provider');
812    if (error) throw error;
813    let landed = !!(data && data.length);
814    if (!landed) {
815        const { error: ierr } = await supabase.from('ai_source_selection')
816            .insert(Object.assign({ user_id: uid, machine_id: mid, provider }, body));
817        if (ierr && ierr.code !== '23505') throw ierr;
818        if (!ierr) landed = true;
819        else {
820            const { data: st2, error: uerr } = await where(supabase.from('ai_source_selection').update(body))
821                .lt('ts', ts).select('provider');
822            if (uerr) throw uerr;
823            landed = !!(st2 && st2.length);
824        }
825    }
826    invalidateSourceSelectionCache(uid);
827    return { landed, machine_id: mid, provider, enabled, requested, ts, by_device: body.by_device };
828}
829
830// Server-validated liveness check. getSession() only reads local storage and
831// can report a zombie session that RLS will still reject as anon — the exact
832// state the 2026-08-24 incident browser sat in — so this mirrors
833// session-resilience.js and asks the server via getUser(). Lets empty reads
834// distinguish "signed out" from "you have no data".
835async function _throwIfSessionDead() {
836    let user = null;
837    try {
838        const { data, error } = await supabase.auth.getUser();
839        // Network error: benefit of the doubt, same as the health check.
840        if (error && /fetch|network|failed/i.test(error.message || '')) return;
841        user = error ? null : data?.user;
842    } catch (_) { return; }
843    if (!user) {
844        const err = new Error('Session expired — sign in again to load synced data');
845        err.code = 'SESSION_EXPIRED';
846        throw err;
847    }
848}
849
850async function _nullOrSessionExpired() {
851    await _throwIfSessionDead();
852    return null; // session is live: this account genuinely has no synced tree yet
853}
854
855// Whether a cached tree is still its device's current one. main_trees'
856// version and last_modified move on every save of that tree (the extension's
857// sync, this portal's saveTreeImmediate, updateNodeFieldsForDevice's bump), so
858// a match means the cached nodes are current and the big tree_nodes read can
859// be skipped. Under the 'latest' alias (no device picked, which is every page
860// load) the cached device must also still BE the most recently active one, as
861// a first visit would pick it. A failed check keeps the cache (an offline
862// reload works as before); a missing row keeps it only when the session
863// cannot be confirmed.
864async function _treeCacheIsCurrent(uid, cached, { latest = false } = {}) {
865    if (!cached?.device_id) return false;
866    try {
867        let query = supabase
868            .from('main_trees')
869            .select('device_id, version, last_modified')
870            .eq('user_id', uid);
871        query = latest
872            ? query.order('last_modified', { ascending: false })
873            : query.eq('device_id', cached.device_id);
874        const { data, error } = await query.limit(1);
875        if (error) return true;
876        const row = data?.[0];
877        if (!row) return !(await _sessionIsLiveFor(uid));
878        return row.device_id === cached.device_id
879            && (row.version ?? 0) === (cached.version ?? 0)
880            && _sameTime(row.last_modified, cached.last_modified);
881    } catch (_) { return true; }
882}
883
884export async function loadTree(uid) {
885    // Check session cache first (avoids the tree_nodes read on page refresh),
886    // but only after the cloud confirms it is still current.
887    const cacheKey = 'tree_' + uid + '_' + (selectedDeviceId || 'latest');
888    let cached = getCached(cacheKey);
889    if (cached && !(await _treeCacheIsCurrent(uid, cached, { latest: !selectedDeviceId }))) {
890        // The device's tree moved on while this tab was not listening: drop
891        // the entry and load exactly as a first visit to this page would.
892        invalidateCache(cacheKey);
893        cached = null;
894    }
895    if (cached) {
896        console.log('[Portal loadTree] using session cache');
897        selectedDeviceId = cached.device_id;
898        treeData = cached.nodes || [];
899        treeVersion = cached.version || 0;
900        _lastSavedTreeNodeIds = new Set(flattenTreeForSync(treeData).map(n => n.id));
901        // Rebaseline: recorded intent is only meaningful relative to the
902        // baseline it was diffed against; the wholesale state replacement
903        // above already discarded any un-saved removals from memory.
904        _pendingTreeDeletions.clear();
905        _treeLoadedForDeviceId = cached.device_id;
906        return cached;
907    }
908
909    // Resolve which device to load
910    let meta;
911    if (selectedDeviceId) {
912        const { data, error } = await supabase
913            .from('main_trees')
914            .select('version, last_modified, device_id, node_count')
915            .eq('user_id', uid)
916            .eq('device_id', selectedDeviceId)
917            .limit(1);
918        if (error) throw error;
919        if (!data || data.length === 0) {
920            // A registered device with no synced tree yet. Render it honestly
921            // empty AND make it saveable — the old fall-through left the
922            // previous device's tree in memory under this device's label,
923            // with the stamp gate then silently refusing every save.
924            await _throwIfSessionDead();
925            treeData = [];
926            treeVersion = 0;
927            _lastSavedTreeNodeIds = new Set();
928            _pendingTreeDeletions.clear();
929            _treeLoadedForDeviceId = selectedDeviceId;
930            return null;
931        }
932        meta = data[0];
933    } else {
934        const { data, error } = await supabase
935            .from('main_trees')
936            .select('version, last_modified, device_id, node_count')
937            .eq('user_id', uid)
938            .order('last_modified', { ascending: false })
939            .limit(1);
940        if (error) { console.error('[Portal loadTree] query error:', error); throw error; }
941        if (!data || data.length === 0) { console.warn('[Portal loadTree] no rows found'); return await _nullOrSessionExpired(); }
942        meta = data[0];
943        selectedDeviceId = meta.device_id;
944    }
945
946    // Fetch nodes from tree_nodes table and reconstruct (paginated)
947    const rows = await fetchAllRows(
948        (sel) => supabase.from('tree_nodes').select('*', sel).eq('user_id', uid).eq('device_id', meta.device_id)
949    );
950
951    // A meta row exists, so an empty node read is suspect: RLS returns zero
952    // rows with NO error when the JWT is dead, indistinguishable from a
953    // genuinely empty tree. Probe before trusting it (2026-08-24 incident:
954    // a browser that kept losing its session rendered an "empty" portal on
955    // every visit while 300 synced rows sat in tree_nodes).
956    if (!rows || rows.length === 0) {
957        await _throwIfSessionDead();
958        const { count, error: cErr } = await supabase
959            .from('tree_nodes')
960            .select('id', { count: 'exact', head: true })
961            .eq('user_id', uid)
962            .eq('device_id', meta.device_id);
963        if (cErr) throw cErr;
964        if ((count || 0) > 0) {
965            const err = new Error(`[Portal loadTree] read 0 of ${count} tree rows — inconsistent read`);
966            err.code = 'INCONSISTENT_READ';
967            throw err;
968        }
969        // The live probe stays the arbiter of "genuinely empty" — a stale
970        // node_count must never brick a device's load. The server-maintained
971        // count (rows as of the last completed save, 2026-08-24 migration) is
972        // a cross-check: a disagreement is worth a loud log even when we
973        // trust the probe and fall through to rendering empty.
974        if ((meta.node_count ?? 0) > 0) {
975            console.warn(`[Portal loadTree] main_trees.node_count says ${meta.node_count} but the live probe found 0 — trusting the probe; the count refreshes on the next save`);
976        }
977        // count === 0: genuinely empty device — render empty, fall through.
978    }
979
980    const nodes = reconstructTree(rows || [], null);
981    treeData = nodes;
982    treeVersion = meta.version || 0;
983    _lastSavedTreeNodeIds = new Set((rows || []).map(r => r.id));
984    _pendingTreeDeletions.clear();
985    _treeLoadedForDeviceId = meta.device_id;
986
987    const result = { ...meta, nodes };
988    // Never cache an empty tree: a cached blank under the 'latest' alias is
989    // served before any query runs and would outlive even a fresh sign-in.
990    // An empty tree is cheap to refetch and expensive to be wrong about.
991    if (nodes.length > 0) setCache(cacheKey, result);
992    console.log('[Portal loadTree] treeData set:', treeData.length, 'nodes, device:', selectedDeviceId);
993    return result;
994}
995
996export function subscribeToTree(uid, callback) {
997    const channel = supabase
998        .channel('portal-tree')
999        .on(
1000            'postgres_changes',
1001            {
1002                event: '*',
1003                schema: 'public',
1004                table: 'main_trees',
1005                filter: `user_id=eq.${uid}`
1006            },
1007            async (payload) => {
1008                const data = payload.new;
1009                if (!data) return;
1010                // Only update if this is the currently selected device
1011                if (selectedDeviceId && data.device_id !== selectedDeviceId) {
1012                    console.log('[Portal subscribeToTree] skipped: device mismatch', data.device_id, '!=', selectedDeviceId);
1013                    return;
1014                }
1015                if (data.last_modified_by === getPageWriterId()) {
1016                    console.log('[Portal subscribeToTree] skipped: own change');
1017                    return;
1018                }
1019
1020                // Fetch nodes from tree_nodes table and reconstruct (paginated)
1021                try {
1022                    const rows = await fetchAllRows(
1023                        (sel) => supabase.from('tree_nodes').select('*', sel).eq('user_id', uid).eq('device_id', data.device_id)
1024                    );
1025
1026                    // An update notification implies the tree exists; an empty
1027                    // read here is far more likely a dead-session RLS blank
1028                    // than a real full clear. Blanking the live view (and
1029                    // caching the blank) is the worse failure — skip, drop the
1030                    // cache, and let the next full load probe honestly.
1031                    if (!rows || rows.length === 0) {
1032                        console.warn('[Portal subscribeToTree] empty node read on update notification — skipping');
1033                        invalidateCache('tree_' + uid + '_' + selectedDeviceId);
1034                        invalidateCache('tree_' + uid + '_latest');
1035                        return;
1036                    }
1037
1038                    const nodes = reconstructTree(rows || [], null);
1039                    treeData = nodes;
1040                    treeVersion = data.version || 0;
1041                    _lastSavedTreeNodeIds = new Set((rows || []).map(r => r.id));
1042                    _pendingTreeDeletions.clear();
1043                    _treeLoadedForDeviceId = data.device_id;
1044
1045                    const result = { ...data, nodes };
1046                    setCache('tree_' + uid + '_' + selectedDeviceId, result);
1047                    console.log('[Portal subscribeToTree] treeData updated:', treeData.length, 'nodes');
1048                    callback(result);
1049                } catch (err) {
1050                    console.error('[Portal subscribeToTree] failed to fetch tree nodes:', err);
1051                }
1052            }
1053        )
1054        .subscribe();
1055
1056    return () => supabase.removeChannel(channel);
1057}
1058
1059let saveTreeTimer = null;
1060export function debouncedSaveTree(uid) {
1061    if (!requirePaidTier()) return;
1062    clearTimeout(saveTreeTimer);
1063    saveTreeTimer = setTimeout(() => saveTreeImmediate(uid), 500);
1064}
1065
1066// Wrapper: every failure inside the save (including the server-side wipe
1067// brake refusing a delete) surfaces as the save-error toast instead of an
1068// unhandled rejection out of the debounce timer, and the optimistic
1069// treeVersion++ is rolled back so the next successful save doesn't skip a
1070// version number.
1071export async function saveTreeImmediate(uid) {
1072    const versionBefore = treeVersion;
1073    const deviceAtSave = selectedDeviceId;
1074    try {
1075        await _saveTreeImmediateInner(uid);
1076    } catch (err) {
1077        if (treeVersion === versionBefore + 1) treeVersion = versionBefore;
1078        // Part of the save may have landed (the nodes) without the main_trees
1079        // touch that moves the stamps, so the cached tree would still pass
1080        // _treeCacheIsCurrent while holding pre-edit nodes, and the next edit
1081        // on it would write them back. Drop it; the next load reads the truth.
1082        if (deviceAtSave) invalidateCache('tree_' + uid + '_' + deviceAtSave);
1083        invalidateCache('tree_' + uid + '_latest');
1084        _dispatchSaveError(err, 'saveTree');
1085    }
1086}
1087
1088async function _saveTreeImmediateInner(uid) {
1089    clearTimeout(saveTreeTimer);
1090    if (!requirePaidTier()) return;
1091    if (!selectedDeviceId) return; // no device selected, can't save
1092    // A failed device switch leaves selectedDeviceId pointing at the new
1093    // device while memory still holds the old device's tree; saving in that
1094    // state grafts one device's tree onto another's rows. Refuse instead.
1095    if (_treeLoadedForDeviceId !== selectedDeviceId) {
1096        // Loud, not a console.warn: this is a sticky refusal (only a
1097        // successful load re-syncs the stamp) and the user deserves to know
1098        // their edits are not persisting.
1099        const err = new Error(`Refusing to save: memory holds device ${_treeLoadedForDeviceId}'s tree but ${selectedDeviceId} is selected`);
1100        _dispatchSaveError(err, 'saveTree device stamp');
1101        return;
1102    }
1103
1104    const flatNodes = flattenTreeForSync(treeData);
1105    const currentIds = new Set(flatNodes.map(n => n.id));
1106    // Only rows whose removal the user actually performed get deleted; rows
1107    // missing from memory without recorded intent are phantoms and are kept
1108    // (see _pendingTreeDeletions above).
1109    const missing = [..._lastSavedTreeNodeIds].filter(id => !currentIds.has(id));
1110    const deletions = missing.filter(id => _pendingTreeDeletions.has(id));
1111    const phantoms = missing.filter(id => !_pendingTreeDeletions.has(id));
1112    if (phantoms.length > 0) {
1113        console.warn(`[Portal saveTree] phantom-delete guard: keeping ${phantoms.length} cloud rows that vanished from memory without a user deletion`, phantoms.slice(0, 10));
1114    }
1115
1116    // Wipe brake FIRST — safety checks run before any fast path. Memory empty
1117    // while the server-tracked set is not means the in-memory tree was emptied
1118    // by something other than the user deleting nodes — a failed load, a
1119    // poisoned state. Syncing it would delete the entire tree (the 2026-08-24
1120    // incident browser got one step from this). Fail the save loudly (toast);
1121    // the owning extension re-syncs the truth. With intent-based deletions
1122    // this state is by definition intent-free, so an ordering where the no-op
1123    // return ran first would silently swallow the one user-visible signal.
1124    if (flatNodes.length === 0 && _lastSavedTreeNodeIds.size > 0) {
1125        const err = new Error(`Refusing to sync an empty tree over ${_lastSavedTreeNodeIds.size} synced rows`);
1126        _dispatchSaveError(err, 'saveTree wipe brake');
1127        return;
1128    }
1129
1130    // Nothing to write, nothing to delete → don't touch main_trees either.
1131    // The old unconditional version bump made every no-op save a Realtime
1132    // pump and let an empty portal save promote itself to "most recently
1133    // modified", stealing the default device pick from the device that
1134    // actually holds the tree.
1135    if (flatNodes.length === 0 && deletions.length === 0) return;
1136
1137    treeVersion++;
1138
1139    // Upsert all nodes to tree_nodes table
1140    const rows = flatNodes.map(n => nodeToTreeRow(n, uid, selectedDeviceId));
1141    if (rows.length > 0) {
1142        await writeWithOfflineFallback('tree_nodes', 'upsert', rows);
1143    }
1144
1145    // Delete removed nodes (single scoped statement — the server-side wipe
1146    // brake judges it as one unit, including on offline replay)
1147    let deleteCommitted = true;
1148    if (deletions.length > 0) {
1149        const delRes = await writeWithOfflineFallback('tree_nodes', 'delete', null,
1150            { user_id: uid, device_id: selectedDeviceId },
1151            { column: 'id', values: deletions });
1152        // null = queued for offline replay, NOT committed. Keep tracking the
1153        // rows and keep the intent so the next online save re-issues the
1154        // delete itself — the queued entry is then redundant rather than
1155        // load-bearing (a dropped queue entry must not resurrect rows).
1156        deleteCommitted = delRes !== null;
1157    }
1158    // Phantom rows stay in cloud, so keep tracking them; deleted ids drop out.
1159    _lastSavedTreeNodeIds = new Set(currentIds);
1160    for (const id of phantoms) _lastSavedTreeNodeIds.add(id);
1161    if (!deleteCommitted) {
1162        for (const id of deletions) _lastSavedTreeNodeIds.add(id);
1163    }
1164    // Prune intent entries that are settled: deleted from cloud, restored to
1165    // the LIVE tree (undo), or never synced at all. Check the live treeData,
1166    // not this save's snapshot — a deletion recorded mid-save must survive
1167    // into the next save.
1168    if (_pendingTreeDeletions.size > 0) {
1169        const liveIds = new Set(flattenTreeForSync(treeData).map(n => n.id));
1170        for (const id of [..._pendingTreeDeletions]) {
1171            if (liveIds.has(id) || !_lastSavedTreeNodeIds.has(id)) _pendingTreeDeletions.delete(id);
1172        }
1173    }
1174
1175    // Touch main_trees metadata row for Realtime notification
1176    await writeWithOfflineFallback('main_trees', 'upsert', {
1177        user_id: uid,
1178        device_id: selectedDeviceId,
1179        version: treeVersion,
1180        last_modified: new Date().toISOString(),
1181        last_modified_by: getPageWriterId()
1182    });
1183
1184    // The tree cache is write-blind otherwise: a portal metadata edit (star,
1185    // tags, memo) saved here would keep rendering pre-edit on a same-tab
1186    // reload. Invalidate rather than rewrite — the payload is the whole tree
1187    // (a large synchronous stringify per debounced save), and the entry can
1188    // live under the 'latest' alias key as well as the device key.
1189    invalidateCache('tree_' + uid + '_' + selectedDeviceId);
1190    invalidateCache('tree_' + uid + '_latest');
1191}
1192
1193// --- LIBRARIES ---
1194
1195// Flatten nested children → flat array with _parentId and _order
1196function flattenLibraryChildren(nodes, parentId) {
1197    const flat = [];
1198    if (!Array.isArray(nodes)) return flat;
1199    for (let i = 0; i < nodes.length; i++) {
1200        const node = nodes[i];
1201        if (!node) continue;
1202        const { children, ...rest } = node;
1203        flat.push({ ...rest, _parentId: parentId, _order: i });
1204        if (children && children.length > 0) {
1205            flat.push(...flattenLibraryChildren(children, node.id));
1206        }
1207    }
1208    return flat;
1209}
1210
1211// Reconstruct nested children from flat array
1212function unflattenLibraryChildren(flatNodes, rootId) {
1213    const byParent = new Map();
1214    for (const node of flatNodes) {
1215        const pid = node._parentId != null ? node._parentId : null;
1216        if (!byParent.has(pid)) byParent.set(pid, []);
1217        byParent.get(pid).push(node);
1218    }
1219    for (const [, arr] of byParent) {
1220        arr.sort((a, b) => (a._order || 0) - (b._order || 0));
1221    }
1222    const build = (parentId) => {
1223        const kids = byParent.get(parentId) || [];
1224        return kids.map(({ _parentId, _order, ...rest }) => ({
1225            ...rest,
1226            children: build(rest.id)
1227        }));
1228    };
1229    return build(rootId);
1230}
1231
1232// Convert library row back to nested format
1233function unflattenLibraryRow(row) {
1234    if (Array.isArray(row.flat_nodes) && row.flat_nodes.length > 0) {
1235        row.children = unflattenLibraryChildren(row.flat_nodes, row.id);
1236    } else {
1237        row.children = [];
1238    }
1239    delete row.flat_nodes;
1240    // Preserve published_at for snapshot UI (camelCase for JS convention)
1241    if (row.published_at !== undefined) {
1242        row.publishedAt = row.published_at;
1243        delete row.published_at;
1244    }
1245    if (row.auto_publish !== undefined) {
1246        row.autoPublish = row.auto_publish;
1247        delete row.auto_publish;
1248    }
1249    return row;
1250}
1251
1252// ─── Granular Sync Helpers ─────────────────────────────────
1253
1254// Flatten a nested tree into a flat array with _parentId and _sortOrder
1255function flattenTreeForSync(nodes, parentId = null) {
1256    const flat = [];
1257    if (!Array.isArray(nodes)) return flat;
1258    for (let i = 0; i < nodes.length; i++) {
1259        const node = nodes[i];
1260        if (!node) continue;
1261        const { children, chromeId, ...rest } = node;
1262        flat.push({ ...rest, _parentId: parentId, _sortOrder: i });
1263        if (children && children.length > 0) {
1264            flat.push(...flattenTreeForSync(children, node.id));
1265        }
1266    }
1267    return flat;
1268}
1269
1270// Reconstruct a nested tree from flat DB rows (tree_nodes or library_nodes)
1271function reconstructTree(flatRows, rootParentId = null) {
1272    const nodeMap = new Map();
1273    const childrenByParent = new Map();
1274    for (const row of flatRows) {
1275        const { parent_id, sort_order, user_id, device_id, field_timestamps, library_id, ...nodeFields } = row;
1276        const node = { ...nodeFields, children: [], _fieldTs: field_timestamps || {} };
1277        // snake_case → camelCase
1278        if (node.fav_icon_url !== undefined) { node.favIconUrl = node.fav_icon_url; delete node.fav_icon_url; }
1279        if (node.custom_title !== undefined) { node.customTitle = node.custom_title; delete node.custom_title; }
1280        if (node.opened_date !== undefined) { node.openedDate = node.opened_date; delete node.opened_date; }
1281        if (node.group_snapshot !== undefined) { node.groupSnapshot = n
1281ode.group_snapshot; delete node.group_snapshot; }
1282        if (node.row_color !== undefined) { node.rowColor = node.row_color; delete node.row_color; }
1283        if (node.row_color_is_custom !== undefined) { node.rowColorIsCustom = node.row_color_is_custom; delete node.row_color_is_custom; }
1284        if (node.memo_text !== undefined) { node.memoText = node.memo_text; delete node.memo_text; }
1285        if (node.star_color !== undefined) { node.starColor = node.star_color; delete node.star_color; }
1286        if (node.last_modified !== undefined) delete node.last_modified;
1287        // Tab Group nodes: the group_snapshot column is repurposed to carry the
1288        // node's own {title, color}. Rehydrate the first-class color and drop
1289        // the snapshot — the renderer reads node.color for the pill, and
1290        // annotateTreeNodes reads parent.color for the member tint, so without
1291        // this every synced tab group renders grey. Mirrors the extension's
1292        // reconstructTree.
1293        if (node.type === 'tabgroup') {
1294            if (node.groupSnapshot && node.groupSnapshot.color) node.color = node.groupSnapshot.color;
1295            if (!node.color) node.color = 'grey';
1296            delete node.groupSnapshot;
1297        }
1298        nodeMap.set(node.id, node);
1299        const pid = parent_id || rootParentId;
1300        if (!childrenByParent.has(pid)) childrenByParent.set(pid, []);
1301        childrenByParent.get(pid).push({ node, sortOrder: sort_order ?? 0 });
1302    }
1303    for (const [, entries] of childrenByParent) {
1304        entries.sort((a, b) => a.sortOrder - b.sortOrder);
1305        for (const { node } of entries) {
1306            const kids = childrenByParent.get(node.id);
1307            if (kids) {
1308                kids.sort((a, b) => a.sortOrder - b.sortOrder);
1309                node.children = kids.map(k => k.node);
1310            }
1311        }
1312    }
1313    // Promote orphans: nodes whose parent_id isn't in the fetched set
1314    for (const [pid, entries] of childrenByParent) {
1315        if (pid !== rootParentId && !nodeMap.has(pid)) {
1316            console.warn(`[Portal Sync] Orphan promotion: ${entries.length} node(s) with missing parent "${pid}" promoted to root`);
1317            const rootEntries = childrenByParent.get(rootParentId) || [];
1318            rootEntries.push(...entries);
1319            childrenByParent.set(rootParentId, rootEntries);
1320        }
1321    }
1322
1323    const roots = childrenByParent.get(rootParentId) || [];
1324    roots.sort((a, b) => a.sortOrder - b.sortOrder);
1325    return roots.map(r => r.node);
1326}
1327
1328// Convert a JS node to a tree_nodes DB row
1329function nodeToTreeRow(flatNode, userId, deviceId) {
1330    return {
1331        id: flatNode.id,
1332        user_id: userId,
1333        device_id: deviceId,
1334        parent_id: flatNode._parentId ?? null,
1335        sort_order: flatNode._sortOrder ?? 0,
1336        type: flatNode.type,
1337        title: flatNode.title ?? '',
1338        url: flatNode.url ?? '',
1339        fav_icon_url: flatNode.favIconUrl ?? flatNode.fav_icon_url ?? '',
1340        collapsed: flatNode.collapsed ?? false,
1341        custom_title: flatNode.customTitle ?? flatNode.custom_title ?? false,
1342        incognito: flatNode.incognito ?? false,
1343        opened_date: flatNode.openedDate ?? flatNode.opened_date ?? null,
1344        // Re-derive the tabgroup identity the reader just unpacked into
1345        // node.color; writing flatNode.groupSnapshot here would send null and
1346        // erase the group's title and color from the cloud. Tabs pass through
1347        // whatever legacy snapshot they still carry rather than being forced to
1348        // null — the portal has no recluster, so it must never destroy an
1349        // encoding it cannot convert. Mirrors the extension's nodeToRow.
1350        group_snapshot: flatNode.type === 'tabgroup'
1351            ? { title: flatNode.title ?? '', color: flatNode.color ?? 'grey' }
1352            : (flatNode.groupSnapshot ?? flatNode.group_snapshot ?? null),
1353        row_color: flatNode.rowColor ?? flatNode.row_color ?? null,
1354        row_color_is_custom: flatNode.rowColorIsCustom ?? flatNode.row_color_is_custom ?? false,
1355        memo_text: flatNode.memoText ?? flatNode.memo_text ?? '',
1356        tags: flatNode.tags ?? [],
1357        star_color: flatNode.starColor ?? flatNode.star_color ?? null,
1358        notes: flatNode.notes ?? [],
1359        last_modified: new Date().toISOString(),
1360        field_timestamps: flatNode._fieldTs ?? {}
1361    };
1362}
1363
1364// Convert a JS node to a library_nodes DB row
1365function nodeToLibraryRow(flatNode, libraryId, userId) {
1366    return {
1367        id: flatNode.id,
1368        library_id: libraryId,
1369        user_id: userId,
1370        parent_id: flatNode._parentId ?? null,
1371        sort_order: flatNode._sortOrder ?? 0,
1372        type: flatNode.type,
1373        title: flatNode.title ?? '',
1374        url: flatNode.url ?? '',
1375        fav_icon_url: flatNode.favIconUrl ?? flatNode.fav_icon_url ?? '',
1376        collapsed: flatNode.collapsed ?? false,
1377        custom_title: flatNode.customTitle ?? flatNode.custom_title ?? false,
1378        opened_date: flatNode.openedDate ?? flatNode.opened_date ?? null,
1379        // Same tabgroup identity carry as nodeToTreeRow.
1380        group_snapshot: flatNode.type === 'tabgroup'
1381            ? { title: flatNode.title ?? '', color: flatNode.color ?? 'grey' }
1382            : (flatNode.groupSnapshot ?? flatNode.group_snapshot ?? null),
1383        row_color: flatNode.rowColor ?? flatNode.row_color ?? null,
1384        row_color_is_custom: flatNode.rowColorIsCustom ?? flatNode.row_color_is_custom ?? false,
1385        memo_text: flatNode.memoText ?? flatNode.memo_text ?? '',
1386        tags: flatNode.tags ?? [],
1387        star_color: flatNode.starColor ?? flatNode.star_color ?? null,
1388        notes: flatNode.notes ?? [],
1389        last_modified: new Date().toISOString(),
1390        field_timestamps: flatNode._fieldTs ?? {}
1391    };
1392}
1393
1394// Build a library object from metadata + granular node/note rows
1395function buildLibraryFromGranular(meta, nodeRows, noteRows) {
1396    const children = reconstructTree(nodeRows || [], meta.id);
1397    const notes = (noteRows || []).map(r => ({ id: r.id, title: r.title, content: r.content, last_modified: r.last_modified ? new Date(r.last_modified).getTime() : 0 }));
1398    const lib = {
1399        id: meta.id,
1400        user_id: meta.user_id,
1401        title: meta.title,
1402        description: meta.description,
1403        isPublic: meta.is_public,
1404        autoPublish: meta.auto_publish,
1405        sharedWith: meta.shared_with || [],
1406        display_order: meta.display_order,
1407        last_modified: meta.last_modified,
1408        last_modified_by: meta.last_modified_by,
1409        children,
1410        notes
1411    };
1412    if (meta.published_at !== undefined) lib.publishedAt = meta.published_at;
1413    if (meta.source_owner_id !== undefined) lib.sourceOwnerId = meta.source_owner_id;
1414    _seedSharedBaseline(lib);
1415    return lib;
1416}
1417
1418// ── A shared library's save diffs against the copy as it arrived (E13) ──
1419// A collaborator's copy of someone else's library can be old: the tab page
1420// re-reads followed libraries hourly. The shared save used to re-send EVERY
1421// row and delete every cloud row its copy lacked, so one edit from an
1422// hour-old copy reverted the owner's newer edits and deleted the rows (and
1423// note pages) the owner had added since. Now the save sends only what changed
1424// since this copy arrived, and deletes only what this copy held and dropped.
1425// The baseline belongs to the library OBJECT built from the cloud rows (edits
1426// replace .children, never the object), so a fresher copy brings its own.
1427const _sharedBaselines = new WeakMap(); // library object → { rows: Map(id → fields), notes: Map(id → fields), title, description }
1428
1429// A row's content fields as JSON strings (exact compare per field).
1430const _SHARED_ROW_SKIP = new Set(['id', 'library_id', 'user_id', 'last_modified', 'field_timestamps']);
1431function _sharedRowFields(row) {
1432    const out = {};
1433    for (const k of Object.keys(row)) if (!_SHARED_ROW_SKIP.has(k)) out[k] = JSON.stringify(row[k]);
1434    return out;
1435}
1436
1437function _sharedNoteFields(note, index) {
1438    return { title: note.title || '', content: note.content || '', sort_order: index };
1439}
1440
1441function _seedSharedBaseline(lib) {
1442    const rows = new Map();
1443    for (const n of flattenTreeForSync(lib.children || [], lib.id)) {
1444        const row = nodeToLibraryRow(n, lib.id, lib.user_id);
1445        rows.set(row.id, _sharedRowFields(row));
1446    }
1447    const notes = new Map();
1448    (Array.isArray(lib.notes) ? lib.notes : []).forEach((note, i) => {
1449        if (note && note.id && !notes.has(note.id)) notes.set(note.id, _sharedNoteFields(note, i));
1450    });
1451    _sharedBaselines.set(lib, { rows, notes, title: lib.title || '', description: lib.description || '' });
1452}
1453
1454// Note pages against the baseline: a new page is inserted whole; a page that
1455// existed is UPDATEd with only the fields that changed (a rename never
1456// re-sends its text from an old copy); a page the copy dropped is deleted.
1457async function _syncSharedLibraryNotes(libraryId, notesArray, ownerId, base) {
1458    const notes = Array.isArray(notesArray) ? notesArray : [];
1459    const stamp = { last_modified: new Date().toISOString(), last_modified_by: getPageWriterId() };
1460    const seen = new Set();
1461    for (let i = 0; i < notes.length; i++) {
1462        const note = notes[i];
1463        if (!note || !note.id || seen.has(note.id)) continue;
1464        seen.add(note.id);
1465        const next = _sharedNoteFields(note, i);
1466        const prev = base.get(note.id);
1467        if (!prev) {
1468            const { error } = await supabase.from('notes').upsert({
1469                id: note.id, user_id: ownerId, owner_type: 'library', owner_id: libraryId, ...next, ...stamp
1470            });
1471            if (error) throw error;
1472        } else {
1473            const patch = {};
1474            for (const k of Object.keys(next)) if (prev[k] !== next[k]) patch[k] = next[k];
1475            if (Object.keys(patch).length === 0) continue;
1476            const { error } = await supabase.from('notes').update({ ...patch, ...stamp })
1477                .eq('id', note.id).eq('owner_type', 'library').eq('owner_id', library
1477Id);
1478            if (error) throw error;
1479        }
1480        base.set(note.id, next);
1481    }
1482    const dropped = [...base.keys()].filter(id => !seen.has(id));
1483    if (dropped.length > 0) {
1484        const { error } = await supabase.from('notes').delete()
1485            .eq('owner_type', 'library').eq('owner_id', libraryId).in('id', dropped);
1486        if (error) throw error;
1487        for (const id of dropped) base.delete(id);
1488    }
1489}
1490
1491// Track known tree node IDs for deletion detection on save
1492let _lastSavedTreeNodeIds = new Set();
1493
1494// Deletions are explicit intent (2026-08-24, incident follow-up item 4): a
1495// cloud row is only deleted when the UI layer recorded the user removing that
1496// node (app.js records it from the undo system's before/after states). A row
1497// missing from memory WITHOUT a recorded intent is a phantom — a failed load,
1498// a torn state — and phantom deletions were one step from wiping the incident
1499// user's tree. Those rows are kept and logged instead of deleted.
1500let _pendingTreeDeletions = new Set();
1501export function markTreeDeletions(ids) {
1502    for (const id of ids || []) if (id) _pendingTreeDeletions.add(id);
1503}
1504
1505// Which device's tree / notes the in-memory state was actually loaded for.
1506// Saves are refused when these disagree with selectedDeviceId — a failed or
1507// half-failed device switch otherwise grafts one device's surface onto the
1508// other's rows (notes have their own stamp because loadNotes can succeed
1509// while loadTree fails, and vice versa).
1510let _treeLoadedForDeviceId = null;
1511let _notesLoadedForDeviceId = null;
1512
1513// Full reset of per-account portal state. Called on sign-out: none of this
1514// may survive into a different account's session in the same tab (a stale
1515// tree + stale baselines + stale recorded intent would render account A's
1516// data to account B and then write it, deletions included, under B's
1517// user_id; stale libraries/notes are the same disclosure with less write
1518// authority).
1519export function clearTreeSyncState() {
1520    treeData = [];
1521    treeVersion = 0;
1522    _lastSavedTreeNodeIds = new Set();
1523    _pendingTreeDeletions.clear();
1524    _treeLoadedForDeviceId = null;
1525    _notesLoadedForDeviceId = null;
1526    libraryData = [];
1527    globalNotes = [];
1528    _panelOrderKnown = false;   // the next account's order must be read before it can be saved
1529    clearTimeout(saveTreeTimer);
1530    saveTreeTimer = null;
1531    clearTimeout(saveNotesTimer);
1532    saveNotesTimer = null;
1533}
1534
1535// Flush a pending debounced save NOW, while the stamps still match the
1536// current selection. The device switcher calls these before flipping
1537// selectedDeviceId — otherwise an edit made in the last ~500ms is dropped by
1538// the stamp gates. No-ops when nothing is pending (never forces a full
1539// re-upsert on an ordinary device click).
1540export async function flushPendingTreeSave(uid) {
1541    if (!saveTreeTimer) return;
1542    clearTimeout(saveTreeTimer);
1543    saveTreeTimer = null;
1544    await saveTreeImmediate(uid);
1545}
1546export async function flushPendingNotesSave(uid) {
1547    if (!saveNotesTimer) return;
1548    clearTimeout(saveNotesTimer);
1549    saveNotesTimer = null;
1550    await saveNotes(uid);
1551}
1552
1553// Sync a notes array to the notes table.
1554// deviceId: for 'global' (main tree) notes, the device that owns this
1555// notes-set. Main tree notes are per-device — rows are scoped by
1556// (owner_type='global', owner_id=user_id, device_id). Pass null for owner
1557// types that aren't device-scoped (library, library_node).
1558async function syncNotesArray(notesArray, ownerType, ownerId, userId, deviceId = null) {
1559    const notes = Array.isArray(notesArray) ? notesArray : [];
1560    const currentIds = new Set();
1561    const upserts = [];
1562    for (let i = 0; i < notes.length; i++) {
1563        const note = notes[i];
1564        if (!note || !note.id) continue;
1565        currentIds.add(note.id);
1566        const row = {
1567            id: note.id,
1568            user_id: userId,
1569            owner_type: ownerType,
1570            owner_id: ownerId,
1571            title: note.title || '',
1572            content: note.content || '',
1573            sort_order: i,
1574            last_modified: new Date().toISOString(),
1575            last_modified_by: getPageWriterId()
1576        };
1577        if (deviceId) row.device_id = deviceId;
1578        upserts.push(row);
1579    }
1580    // Snapshot existing note IDs BEFORE upsert. When deviceId is provided,
1581    // scope to that device's notes-set so we never see or delete another
1582    // device's rows that share the same (owner_type, owner_id) pair.
1583    let snapshotQuery = supabase.from('notes').select('id')
1584        .eq('owner_type', ownerType).eq('owner_id', ownerId).eq('user_id', userId);
1585    if (deviceId) snapshotQuery = snapshotQuery.eq('device_id', deviceId);
1586    const { data: existing } = await snapshotQuery;
1587    const previousIds = new Set((existing || []).map(r => r.id));
1588
1589    if (upserts.length > 0) {
1590        await writeWithOfflineFallback('notes', 'upsert', upserts);
1591    }
1592    // Delete notes that existed before this sync but are no longer in the array.
1593    // previousIds is already device-scoped (when applicable). If the snapshot
1594    // select failed offline, previousIds is empty and this phase is a no-op —
1595    // the next online sync recomputes it. Errors here were never checked, so
1596    // keep them non-fatal.
1597    const toDelete = [...previousIds].filter(id => !currentIds.has(id));
1598    if (toDelete.length > 0) {
1599        await writeWithOfflineFallback('notes', 'delete', null,
1600            { user_id: userId }, { column: 'id', values: toDelete }
1601        ).catch(err => console.warn('[Portal syncNotesArray] note prune failed:', err?.message || String(err)));
1602    }
1603}
1604
1605// ─── Data Functions ────────────────────────────────────────
1606
1607export async function loadLibraries(uid) {
1608    // Linked libraries are always fetched fresh (they can change at any time);
1609    // the entity-order doc rides the same round-trip, and so does the cloud
1610    // check of a cached owned set (_freshenCachedLibraries). The cache is read
1611    // BEFORE that check starts, so whatever the check finds is newer than 
1611it.
1612    const cached = getCached('libraries_' + uid);
1613    const [linked, , stamps] = await Promise.all([
1614        loadLinkedLibrariesForUser(uid),
1615        ensureEntityOrder(uid),
1616        cached ? _fetchOwnedLibraryStamps(uid) : null
1617    ]);
1618
1619    if (cached) {
1620        console.log('[Portal loadLibraries] using session cache');
1621        const owned = await _freshenCachedLibraries(uid, cached, stamps);
1622        libraryData = [...owned, ...linked].sort(compareLibraryOrder);
1623        return libraryData;
1624    }
1625
1626    // Fetch library metadata
1627    const { data: metas, error: metaErr } = await supabase
1628        .from('libraries')
1629        .select('*')
1630        .eq('user_id', uid)
1631        .order('display_order', { ascending: true });
1632    if (metaErr) throw metaErr;
1633    if (!metas || metas.length === 0) { libraryData = [...linked]; return libraryData; }
1634
1635    const libraryIds = metas.map(m => m.id);
1636
1637    // Batch fetch all library nodes + notes (paginated — Supabase caps at 1000 rows)
1638    const [allNodes, allNotes] = await Promise.all([
1639        fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).in('library_id', libraryIds)),
1640        fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').in('owner_id', libraryIds).order('sort_order', { ascending: true }))
1641    ]);
1642
1643    const nodesByLib = new Map();
1644    for (const row of allNodes) {
1645        if (!nodesByLib.has(row.library_id)) nodesByLib.set(row.library_id, []);
1646        nodesByLib.get(row.library_id).push(row);
1647    }
1648    const notesByLib = new Map();
1649    for (const row of allNotes) {
1650        if (!notesByLib.has(row.owner_id)) notesByLib.set(row.owner_id, []);
1651        notesByLib.get(row.owner_id).push(row);
1652    }
1653
1654    const owned = metas.map(meta => buildLibraryFromGranular(meta, nodesByLib.get(meta.id), notesByLib.get(meta.id)))
1655        .sort(compareLibraryOrder);
1656    libraryData = [...owned, ...linked];
1657    setCache('libraries_' + uid, owned);
1658    return libraryData;
1659}
1660
1661// The owned libraries' change stamps, for _freshenCachedLibraries. Null when
1662// the check itself fails (offline, say): the cache is then served as it is.
1663async function _fetchOwnedLibraryStamps(uid) {
1664    try {
1665        const { data, error } = await supabase
1666            .from('libraries')
1667            .select('id, last_modified, display_order, published_at')
1668            .eq('user_id', uid);
1669        return error ? null : (data || []);
1670    } catch (_) { return null; }
1671}
1672
1673// Owned libraries by id, built as loadLibraries builds them.
1674async function _fetchLibrariesByIds(ids) {
1675    const [metasResult, allNodes, allNotes] = await Promise.all([
1676        supabase.from('libraries').select('*').in('id', ids),
1677        fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).in('library_id', ids)),
1678        fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').in('owner_id', ids).order('sort_order', { ascending: true }))
1679    ]);
1680    if (metasResult.error) throw metasResult.error;
1681    const nodesByLib = new Map();
1682    for (const row of allNodes) {
1683        if (!nodesByLib.has(row.library_id)) nodesByLib.set(row.library_id, []);
1684        nodesByLib.get(row.library_id).push(row);
1685    }
1686    const notesByLib = new Map();
1687    for (const row of allNotes) {
1688        if (!notesByLib.has(row.owner_id)) notesByLib.set(row.owner_id, []);
1689        notesByLib.get(row.owner_id).push(row);
1690    }
1691    return (metasResult.data || []).map(meta => buildLibraryFromGranular(meta, nodesByLib.get(meta.id), notesByLib.get(meta.id)));
1692}
1693
1694/**
1695 * Bring a cached owned set up to date with the cloud before it is served.
1696 *
1697 * The cache is this tab's copy of the owned libraries as the tab last saw
1698 * them. Realtime keeps it current only while the page is open AND hears the
1699 * write, so a write it missed (another tab's, which the echo filter dropped
1700 * until 2026-09-30; one made while the socket was down; one made during the
1701 * reload itself) stayed invisible, reload after reload, until the tab closed.
1702 *
1703 * Every content write re-touches the library's `libraries` row last
1704 * (saveLibrary step 4 here, _syncLibraryToCloudImpl in the extension), so a
1705 * cached library whose last_modified matches the cloud's is current: keep it.
1706 * Refetch the ones that differ and the ones created elsewhere; drop the ones
1707 * deleted elsewhere. A reorder and a publish do not bump last_modified, so
1708 * their two fields are copied across. Any failure serves the cache as it is,
1709 * which is what a load did before.
1710 */
1711async function _freshenCachedLibraries(uid, cached, stamps) {
1712    if (!stamps) return cached;
1713    try {
1714        // An empty answer is also what a dead session reads through RLS; an
1715        // expired sign-in must not empty the panel.
1716        if (stamps.length === 0 && cached.length > 0 && !(await _sessionIsLiveFor(uid))) return cached;
1717        const sameTime = _sameTime;
1718        const cloud = new Map(stamps.map(r => [r.id, r]));
1719        const kept = [];
1720        const staleIds = [];
1721        let changed = false;
1722        for (const f of cached) {
1723            const row = f?.id ? cloud.get(f.id) : null;
1724            if (!row) { changed = true; continue; }   // deleted elsewhere
1725            if (!sameTime(f.last_modified, row.last_modified)) { staleIds.push(f.id); continue; }
1726            if (f.display_order !== row.display_order) { f.display_order = row.display_order; changed = true; }
1727            if (!sameTime(f.publishedAt ?? null, row.published_at ?? null)) { f.publishedAt = row.published_at; changed = true; }
1728            kept.push(f);
1729        }
1730        const cachedIds = new Set(cached.map(f => f?.id));
1731        for (const row of stamps) if (!cachedIds.has(row.id)) staleIds.push(row.id);
1732        if (staleIds.length === 0 && !changed) return cached;
1733        // Only the stamps read decides that a library is gone. One the refetch
1734        // did not return (deleted in between, or an empty read) keeps its
1735        // cached copy; the next load's stamps settle it.
1736        const fetched = staleIds.length ? await _fetchLibrariesByIds(staleIds) : [];
1737        const fetchedIds = new Set(fetched.map(f => f.id));
1738        const owned = [...kept, ...fetched,
1739            ...cached.filter(f => f?.id && staleIds.includes(f.id) && !fetchedIds.has(f.id))];
1740        if (!setCache('libraries_' + uid, owned)) invalidateLibrariesCache(uid);
1741        return owned;
1742    } catch (err) {
1743        console.warn('[Portal loadLibraries] cloud check failed, serving the cache:', err?.message || String(err));
1744        return cached;
1745    }
1746}
1747
1748// Fetch all linked (live-reference) libraries for the user and annotate with access level.
1749async function loadLinkedLibrariesForUser(uid) {
1750    try {
1751        const { data: links, error: linksErr } = await supabase
1752            .from('linked_libraries')
1753            .select('library_id')
1754            .eq('user_id', uid);
1755        if (linksErr) { console.error('[loadLinkedLibraries] link fetch error:', linksErr); return []; }
1756        if (!links?.length) return [];
1757
1758        const uniqueIds = [...new Set(links.map(l => l.library_id))];
1759
1760        const results = await Promise.all(uniqueIds.map(async (library_id) => {
1761            const { data: meta, error: metaErr } = await supabase
1762                .from('libraries')
1763                .select('*')
1764                .eq('id', library_id)
1765                .single();
1766            if (metaErr || !meta) { console.warn('[loadLinkedLibraries] library fetch failed for', library_id, metaErr); return null; }
1767
1768            const { access, viaShare } = await getMyLibraryAccess(meta.id, uid);
1769            if (!access) return null;
1770
1771            // Fetch nodes + notes from granular tables (paginated)
1772            const [nodeRows, noteRows] = await Promise.all([
1773                fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).eq('library_id', library_id)),
1774                fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').eq('owner_id', library_id).order('sort_order', { ascending: true }))
1775            ]);
1776
1777            const lib = buildLibraryFromGranular(meta, nodeRows, noteRows);
1778            lib._linkedRef = true;
1779            lib._linkedAccessLevel = access;
1780            lib._linkedViaShare = !!viaShare;
1781            return lib;
1782        }));
1783        return results.filter(Boolean);
1784    } catch (err) { console.error('[loadLinkedLibraries] unexpected error:', err); return []; }
1785}
1786
1787export async function addLinkedLibrary(uid, libraryId) {
1788    // Check first to avoid duplicate rows (table has no unique constraint)
1789    const { data: existing } = await supabase
1790        .from('linked_libraries')
1791        .select('library_id')
1792        .eq('user_id', uid)
1793        .eq('library_id', libraryId)
1794        .maybeSingle();
1795    if (existing) return;
1796    const { error } = await supabase
1797        .from('linked_libraries')
1798        .insert({ user_id: uid, library_id: libraryId });
1799    if (error) throw error;
1800}
1801
1802export async function removeLinkedLibrary(uid, libraryId) {
1803    const { error } = await supabase
1804        .from('linked_libraries')
1805        .delete()
1806        .eq('user_id', uid)
1807        .eq('library_id', libraryId);
1808    if (error) throw error;
1809}
1810
1811// Load a single library by ID (for standalone library page)
1812export async function loadLibraryById(libraryId) {
1813    const { data: meta, error } = await supabase
1814        .from('libraries')
1815        .select('*')
1816        .eq('id', libraryId)
1817        .single();
1818
1819    if (error) {
1820        if (error.code === 'PGRST116') return null; // not found
1821        throw error;
1822    }
1823
1824    // Fetch nodes + notes from granular tables (paginated)
1825    const [nodeRows, noteRows] = await Promise.all([
1826        fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).eq('library_id', libraryId)),
1827        fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').eq('owner_id', libraryId).order('sort_order', { ascending: true }))
1828    ]);
1829
1830    return buildLibraryFromGranular(meta, nodeRows, noteRows);
1831}
1832
1833// Subscribe to a single library's real-time changes
1834export function subscribeToLibrary(libraryId, callback) {
1835    let debounceTimer = null;
1836
1837    const channel = supabase
1838        .channel(`portal-library-${libraryId}`)
1839        .on(
1840            'postgres_changes',
1841            {
1842                event: '*',
1843                schema: 'public',
1844                table: 'libraries',
1845                filter: `id=eq.${libraryId}`
1846            },
1847            (payload) => {
1848                if (payload.new?.last_modified_by === getPageWriterId()) return;
1849                if (payload.eventType === 'DELETE') return;
1850
1851                clearTimeout(debounceTimer);
1852                debounceTimer = setTimeout(async () => {
1853                    // Fetch metadata + nodes + notes from granular tables (paginated)
1854                    const [metaResult, nodeRows, noteRows] = await Promise.all([
1855                        supabase.from('libraries').select('*').eq('id', libraryId).single(),
1856                        fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).eq('library_id', libraryId)),
1857                        fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').eq('owner_id', libraryId).order('sort_order', { ascending: true }))
1858                    ]);
1859                    if (metaResult.data) {
1860                        const folder = buildLibraryFromGranular(metaResult.data, nodeRows, noteRows);
1861                        if (folder) callback(folder);
1862                    }
1863                }, 500);
1864            }
1865        )
1866        .subscribe();
1867
1868    return () => { clearTimeout(debounceTimer); supabase.removeChannel(channel); };
1869}
1870
1871export function subscribeToLibraries(uid, callback) {
1872    setLinkedPollContext(uid, callback);
1873    let debounceTimer = null;
1874    let pendingEvents = [];
1875
1876    const channel = supabase
1877        .channel('portal-libraries')
1878        .on(
1879            'postgres_changes',
1880            {
1881                event: '*',
1882                schema: 'public',
1883                table: 'libraries',
1884                filter: `user_id=eq.${uid}`
1885            },
1886            (payload) => {
1887                if (payload.new?.last_modified_by === getPageWriterId()) return;
1888                if (payload.eventType === 'DELETE' && payload.old?.last_modified_by === getPageWriterId()) return;
1889
1890                const eventType = payload.eventType;
1891
1892                if (eventType === 'DELETE') {
1893                    pendingEvents.push({ type: 'delete', id: payload.old?.id });
1894                } else {
1895                    // Use payload only as a notification — fetch full row via REST
1896                    // to avoid Supabase realtime payload size limits (~1MB)
1897                    pendingEvents.push({ type: eventType.toLowerCase(), id: payload.new?.id });
1898                }
1899
1900                // Debounce rapid events (e.g. bulk reorder)
1901                clearTimeout(debounceTimer);
1902                debounceTimer = setTimeout(async () => {
1903                    const events = pendingEvents;
1904                    pendingEvents = [];
1905
1906                    const idsToFetch = [...new Set(
1907                        events.filter(e => e.type !== 'delete' && e.id).map(e => e.id)
1908                    )];
1909                    const fetchedLibraries = {};
1910                    if (idsToFetch.length > 0) {
1911                        // Fetch metadata + nodes + notes from granular tables (paginated)
1912                        const [metasResult, allNodes, allNotes] = await Promise.all([
1913                            supabase.from('libraries').select('*').in('id', idsToFetch),
1914                            fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).in('library_id', idsToFetch)),
1915                            fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').in('owner_id', idsToFetch).order('sort_order', { ascending: true }))
1916                        ]);
1917                        const nodesByLib = new Map();
1918                        for (const row of allNodes) {
1919                            if (!nodesByLib.has(row.library_id)) nodesByLib.set(row.library_id, []);
1920                            nodesByLib.get(row.library_id).push(row);
1921                        }
1922                        const notesByLib = new Map();
1923                        for (const row of allNotes) {
1924                            if (!notesByLib.has(row.owner_id)) notesByLib.set(row.owner_id, []);
1925                            notesByLib.get(row.owner_id).push(row);
1926                        }
1927                        for (const meta of (metasResult.data || [])) {
1928                            fetchedLibraries[meta.id] = buildLibraryFromGranular(meta, nodesByLib.get(meta.id), notesByLib.get(meta.id));
1929                        }
1930                    }
1931
1932                    for (const event of events) {
1933                        if (event.type === 'delete' && event.id) {
1934                            libraryData = libraryData.filter(f => f?.id !== event.id);
1935                        } else if (event.type === 'insert' && fetchedLibraries[event.id]) {
1936                            if (!libraryData.some(f => f?.id === event.id)) {
1937                                libraryData.push(fetchedLibraries[event.id]);
1938                            }
1939                        } else if (event.type === 'update' && fetchedLibraries[event.id]) {
1940                            const idx = libraryData.findIndex(f => f?.id === event.id);
1941                            if (idx >= 0) {
1942                                libraryData[idx] = fetchedLibraries[event.id];
1943                            } else {
1944                                libraryData.push(fetchedLibraries[event.id]);
1945                            }
1946                        }
1947                    }
1948
1949                    // Entity-doc order when loaded, display_order fallback —
1950                    // a raw display_order sort here would scramble doc-applied
1951                    // order on every realtime row update (the column is
1952                    // vestigial for cutover-era extensions).
1953                    libraryData.sort(compareLibraryOrder);
1954
1955                    setCache('libraries_' + uid, libraryData.filter(f => !f?._linkedRef));
1956                    callback(libraryData);
1957                }, 500);
1958            }
1959        )
1960        .subscribe();
1961
1962    // Subscribe to linked_libraries table — fires when user adds/removes a live-reference library
1963    const linkedChannel = supabase
1964        .channel('portal-linked-libraries')
1965        .on('postgres_changes', {
1966            event: '*',
1967            schema: 'public',
1968            table: 'linked_libraries',
1969            filter: `user_id=eq.${uid}`
1970        }, async (payload) => {
1971            if (payload.eventType === 'DELETE') {
1972                const removedId = payload.old?.library_id;
1973                if (removedId) {
1974                    libraryData = libraryData.filter(f => !(f?._linkedRef && f?.id === removedId));
1975                    setCache('libraries_' + uid, libraryData.filter(f => !f?._linkedRef));
1976                    callback(libraryData);
1977                }
1978            } else if (payload.eventType === 'INSERT') {
1979                const newId = payload.new?.library_id;
1980                if (newId && !libraryData.some(f => f?._linkedRef && f?.id === newId)) {
1981                    try {
1982                        const [metaResult, nodeRows, noteRows] = await Promise.all([
1983                            supabase.from('libraries').select('*').eq('id', newId).single(),
1984                            fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).eq('library_id', newId)),
1985                            fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').eq('owner_id', newId).order('sort_order', { ascending: true }))
1986                        ]);
1987                        if (metaResult.data) {
1988                            const { access, viaShare } = await getMyLibraryAccess(newId, uid);
1989                            if (access) {
1990                                const lib = buildLibraryFromGranular(metaResult.data, nodeRows, noteRows);
1991                                lib._linkedRef = true;
1992                                lib._linkedAccessLevel = access;
1993                                lib._linkedViaShare = !!viaShare;
1994                                libraryData.push(lib);
1995                                setCache('libraries_' + uid, libraryData.filter(f => !f?._linkedRef));
1996                                callback(libraryData);
1997                            }
1998                        }
1999                    } catch { /* ignore */ }
2000                }
2001            }
2002        })
2003        .subscribe();
2004
2005    // Poll for linked library content changes only. Owned library INSERT/DELETE
2006    // events are handled by Realtime (REPLICA IDENTITY FULL is set on the table).
2007    // Linked libraries need polling because edits by the owner don't trigger our
2008    // user_id-scoped Realtime subscription.
2009    const pollTimer = setInterval(() => pollLinkedLibraryChanges(uid, callback), 3600000); // 1 hour
2010
2011    return () => { clearTimeout(debounceTimer); clearInterval(pollTimer); supabase.removeChannel(channel);
2011 supabase.removeChannel(linkedChannel); };
2012}
2013
2014// (REMOVED — Phase 3 S5b.) subscribeToLibraryTombstones /
2015// subscribeToLibraryGroupTombstones lived here. Under cutover, library/group
2016// existence deletes on the portal are handled by the libraries/library_groups
2017// DELETE events in subscribeToLibraries / subscribeToLibraryGroups; the
2018// tombstone tables are being dropped (sql/drop-tombstones.sql), so these
2019// redundant backup subscriptions are gone to avoid pointing live channels at
2020// dropped tables.
2021
2022async function pollLinkedLibraryChanges(uid, callback) {
2023    const linkedEntries = libraryData.filter(f => f?._linkedRef);
2024    if (linkedEntries.length === 0) return false;
2025
2026    try {
2027        const linkedIds = linkedEntries.map(f => f.id);
2028        const { data: cloudLinked } = await supabase
2029            .from('libraries')
2030            .select('id, last_modified')
2031            .in('id', linkedIds);
2032        if (!cloudLinked) return false;
2033
2034        let changed = false;
2035        const updatedIds = cloudLinked
2036            .filter(r => {
2037                const local = linkedEntries.find(f => f.id === r.id);
2038                return local && r.last_modified !== local.last_modified;
2039            })
2040            .map(r => r.id);
2041
2042        // Detect removed linked libraries (deleted by owner or access revoked)
2043        const cloudLinkedIds = new Set(cloudLinked.map(r => r.id));
2044        const removedIds = linkedIds.filter(id => !cloudLinkedIds.has(id));
2045        if (removedIds.length > 0) {
2046            libraryData = libraryData.filter(f => !(f?._linkedRef && removedIds.includes(f?.id)));
2047            changed = true;
2048        }
2049
2050        if (updatedIds.length > 0) {
2051            // Fetch metadata + nodes + notes from granular tables (paginated)
2052            const [metasResult, allNodes, allNotes] = await Promise.all([
2053                supabase.from('libraries').select('*').in('id', updatedIds),
2054                fetchAllRows((sel) => supabase.from('library_nodes').select('*', sel).in('library_id', updatedIds)),
2055                fetchAllRows((sel) => supabase.from('notes').select('*', sel).eq('owner_type', 'library').in('owner_id', updatedIds).order('sort_order', { ascending: true }))
2056            ]);
2057            const nodesByLib = new Map();
2058            for (const row of allNodes) {
2059                if (!nodesByLib.has(row.library_id)) nodesByLib.set(row.library_id, []);
2060                nodesByLib.get(row.library_id).push(row);
2061            }
2062            const notesByLib = new Map();
2063            for (const row of allNotes) {
2064                if (!notesByLib.has(row.owner_id)) notesByLib.set(row.owner_id, []);
2065                notesByLib.get(row.owner_id).push(row);
2066            }
2067            for (const meta of (metasResult.data || [])) {
2068                const idx = libraryData.findIndex(f => f?._linkedRef && f?.id === meta.id);
2069                if (idx >= 0) {
2070                    const { access, viaShare } = await getMyLibraryAccess(meta.id, uid);
2071                    if (access) {
2072                        const lib = buildLibraryFromGranular(meta, nodesByLib.get(meta.id), notesByLib.get(meta.id));
2073                        lib._linkedRef = true;
2074                        lib._linkedAccessLevel = access;
2075                        lib._linkedViaShare = !!viaShare;
2076                        libraryData[idx] = lib;
2077                    } else {
2078                        libraryData.splice(idx, 1); // access revoked
2079                    }
2080                    changed = true;
2081                }
2082            }
2083        }
2084
2085        if (changed) {
2086            setCache('libraries_' + uid, libraryData.filter(f => !f?._linkedRef));
2087            callback(libraryData);
2088        }
2089        return changed;
2090    } catch { return false; }
2091}
2092
2093// Manual refresh for linked libraries — called by the UI refresh button
2094let _linkedPollUid = null;
2095let _linkedPollCallback = null;
2096export function setLinkedPollContext(uid, callback) {
2097    _linkedPollUid = uid;
2098    _linkedPollCallback = callback;
2099}
2100export async function refreshLinkedLibraries() {
2101    if (!_linkedPollUid || !_linkedPollCallback) return false;
2102    return pollLinkedLibraryChanges(_linkedPollUid, _linkedPollCallback);
2103}
2104
2105// ── One write at a time per library ─────────────────────────────────────────
2106//
2107// saveLibrary and updateSharedLibrary each write a library's WHOLE tree: they
2108// upsert every row, then delete every stored row the tree lacks. Two writes of
2109// one library that overlap can undo each other. Delete a row and press Ctrl+Z
2110// quickly: if the delete's write (tree without the row) reaches its prune after
2111// the undo's write (tree with the row) has upserted it, the restored row is
2112// deleted from the cloud while the page still shows it (2026-09-27). So the
2113// writes of one library run one at a time, in call order, and deleteLibrary
2114// waits its turn too. A write asked for after the delete is dropped
2115// (_deletedLibraryIds): it would bring the library back, and it is reachable,
2116// because the Delete button's window.confirm holds a pending save timer until
2117// the click has queued the delete.
2118//
2119// A write that has to wait writes the library as it is when its turn comes,
2120// not as it was when it was asked for. Its children are read when it runs, and
2121// calls that wait together share one write of the newest call's arguments (a
2122// write covers the whole tree, so the later one decides the stored state
2123// either way). A call made with the object the page shows (setLiveLibraryGetter)
2124// writes the object the page shows when the turn comes: a realtime update can
2125// replace it meanwhile, and writing the older object would prune what the
2126// newer one brought. A call made with any other object (a picker's own copy)
2127// writes that object. The extension's library push works the same way
2128// (src/supabase-sync.js _runLibrarySync and setLiveLibraryIdsGetter).
2129const _libraryWriteQueues = new Map(); // libraryId -> { busy, jobs: [{ kind, args, write, promise, resolve, reject }] }
2130const _deletedLibraryIds = new Set();  // deletes asked for on this page (cleared again if one fails)
2131let _liveLibraryGetter = null;
2132
2133// fn(libraryId) returns the object this page shows for that library, or a
2134// falsy value when the page does not show it.
2135export function setLiveLibraryGetter(fn) {
2136    _liveLibraryGetter = typeof fn === 'function' ? fn : null;
2137}
2138
2139// True once this page has asked to delete the library: its later writes are
2140// dropped, so a step that must reach it (a Move's receiving side) cannot.
2141export function isLibraryDeletePending(libraryId) {
2142    return _deletedLibraryIds.has(libraryId);
2143}
2144
2145function _isPageLibraryFolder(folder) {
2146    if (!_liveLibraryGetter || !folder?.id) return false;
2147    try { return _liveLibraryGetter(folder.id) === folder; } catch { return false; }
2148}
2149
2150function _libraryFolderToWrite(folder, wasPageFolder) {
2151    if (!wasPageFolder) return folder;
2152    try {
2153        const live = _liveLibraryGetter?.(folder.id);
2154        return (live && live.id === folder.id) ? live : folder;
2155    } catch { return folder; }
2156}
2157
2158function _queueLibraryWrite(libraryId, kind, args, write) {
2159    let q = _libraryWriteQueues.get(libraryId);
2160    if (!q) { q = { busy: false, jobs: [] }; _libraryWriteQueues.set(libraryId, q); }
2161    const waiting = q.jobs[q.jobs.length - 1];
2162    if (waiting && waiting.kind === kind && kind !== 'delete') {
2163        waiting.args = args; // the newest call's arguments; both callers share the one write
2164        return waiting.promise;
2165    }
2166    const job = { kind, args, write };
2167    job.promise = new Promise((resolve, reject) => { job.resolve = resolve; job.reject = reject; });
2168    q.jobs.push(job);
2169    if (!q.busy) _drainLibraryWrites(libraryId, q);
2170    return job.promise;
2171}
2172
2173async function _drainLibraryWrites(libraryId, q) {
2174    q.busy = true;
2175    try {
2176        while (q.jobs.length > 0) {
2177            const job = q.jobs.shift();
2178            try { job.resolve(await job.write(...job.args)); }
2179            catch (err) { job.reject(err); }
2180        }
2181    } finally {
2182        q.busy = false;
2183        if (_libraryWriteQueues.get(libraryId) === q) _libraryWriteQueues.delete(libraryId);
2184    }
2185}
2186
2187export async function saveLibrary(uid, folder, displayOrder = 0) {
2188    if (!requirePaidTier()) return;
2189    if (_deletedLibraryIds.has(folder?.id)) return;
2190    return _queueLibraryWrite(folder?.id, 'own',
2191        [uid, folder, displayOrder, _isPageLibraryFolder(folder)], _saveLibraryNow);
2192}
2193
2194async function _saveLibraryNow(uid, queuedFolder, displayOrder, wasPageFolder) {
2195    const folder = _libraryFolderToWrite(queuedFolder, wasPageFolder);
2196    try {
2197        const { children, notes: libraryNotes, ...meta } = folder;
2198        const libraryId = folder.id;
2199
2200        // 1. Upsert library metadata (without flat_nodes or notes)
2201        await writeWithOfflineFallback('libraries', 'upsert', {
2202            id: libraryId,
2203            user_id: uid,
2204            title: meta.title || '',
2205            description: meta.description || '',
2206            is_public: meta.isPublic || false,
2207            auto_publish: meta.autoPublish || false,
2208            display_order: displayOrder,
2209            last_modified: new Date().toISOString(),
2210            last_modified_by: getPageWriterId()
2211        });
2212
2213        // 2. Upsert library nodes to library_nodes table
2214        const flatNodes = flattenTreeForSync(children || [], libraryId);
2215        const currentIds = new Set(flatNodes.map(n => n.id));
2216        const rows = flatNodes.map(n => nodeToLibraryRow(n, libraryId, uid));
2217        if (rows.length > 0) {
2218            await writeWithOfflineFallback('library_nodes', 'upsert', rows);
2219        }
2220
2221        // Delete nodes no longer in tree. The prune needs a cloud snapshot,
2222        // so offline it's skipped for this pass — the next online save
2223        // recomputes the delta and prunes then.
2224        let existingNodes = null;
2225        try {
2226            existingNodes = await fetchAllRows((sel) => supabase.from('library_nodes').select('id', sel).eq('library_id', libraryId));
2227        } catch (selErr) {
2228            if (!navigator.onLine || isNetworkError(selErr)) {
2229                console.warn('[Portal Offline] Offline — skipping library_nodes prune this save');
2230            } else {
2231                throw selErr;
2232            }
2233        }
2234        if (existingNodes) {
2235            const toDelete = existingNodes.filter(r => !currentIds.has(r.id)).map(r => r.id);
2236            if (toDelete.length > 0) {
2237                // Prune errors were never checked here; keep them non-fatal.
2238                await writeWithOfflineFallback('library_nodes', 'delete', null,
2239                    { library_id: libraryId }, { column: 'id', values: toDelete }
2240                ).catch(err =>
2240 console.warn('[Portal saveLibrary] node prune failed:', err?.message || String(err)));
2241            }
2242        }
2243
2244        // 3. Sync library-level notes to notes table
2245        await syncNotesArray(libraryNotes || [], 'library', libraryId, uid);
2246
2247        // 4. Final metadata touch — ensures Realtime notification fires AFTER
2248        // nodes+notes are committed. Errors were never checked here; keep
2249        // them non-fatal.
2250        await writeWithOfflineFallback('libraries', 'upsert', {
2251            id: libraryId, user_id: uid, last_modified: new Date().toISOString(),
2252            last_modified_by: getPageWriterId()
2253        }).catch(err => console.warn('[Portal saveLibrary] metadata touch failed:', err?.message || String(err)));
2254
2255        // The write landed; keep the session cache in step or a reload in this
2256        // tab will show pre-edit content (see syncLibrariesCache).
2257        syncLibrariesCache(uid, folder);
2258    } catch (err) {
2259        // Part of the write may have gone through, so the cache can no longer
2260        // be trusted to describe what is stored. Drop it and let the next load
2261        // read the truth.
2262        invalidateLibrariesCache(uid);
2263        _dispatchSaveError(err, 'saveLibrary');
2264        throw err;
2265    }
2266}
2267
2268// Update a shared library's content (uses .update(), not .upsert(),
2269// because shared editors only have UPDATE permission via RLS)
2270// Queued with the owner's saves of the same library (see _queueLibraryWrite).
2271export async function updateSharedLibrary(libraryId, folder) {
2272    if (_deletedLibraryIds.has(libraryId)) return;
2273    return _queueLibraryWrite(libraryId, 'shared',
2274        [libraryId, folder, _isPageLibraryFolder(folder)], _updateSharedLibraryNow);
2275}
2276
2277async function _updateSharedLibraryNow(libraryId, queuedFolder, wasPageFolder) {
2278    const folder = _libraryFolderToWrite(queuedFolder, wasPageFolder);
2279    const { children, notes: libraryNotes, ...meta } = folder;
2280    // Every copy a write-shared page edits is built from cloud rows and so has
2281    // a baseline. Without one there is no safe write: sending every row would
2282    // undo the owner's newer edits. Fail loudly instead.
2283    const base = _sharedBaselines.get(folder) || null;
2284    if (!base) throw new Error('This shared library copy cannot be saved. Reload the page and make the change again.');
2285
2286    // 1. Title and description, each only when this user changed it:
2287    //    re-sending an unchanged field from an old copy undid the owner's
2288    //    newer edit to it.
2289    const title = meta.title || '';
2290    const description = meta.description || '';
2291    const metaPatch = {};
2292    if (base.title !== title) metaPatch.title = title;
2293    if (base.description !== description) metaPatch.description = description;
2294    if (Object.keys(metaPatch).length > 0) {
2295        const { data: metaRows, error: metaErr } = await supabase
2296            .from('libraries')
2297            .update({
2298                ...metaPatch,
2299                last_modified: new Date().toISOString(),
2300                last_modified_by: getPageWriterId()
2301            })
2302            .eq('id', libraryId)
2303            .select('id');
2304        if (metaErr) throw metaErr;
2305        // Row-level security turns a refused UPDATE into zero rows, no error.
2306        if (!metaRows || metaRows.length === 0) throw new Error('The library owner no longer gives you edit access.');
2307        if ('title' in metaPatch) base.title = title;
2308        if ('description' in metaPatch) base.description = description;
2309    }
2310
2311    // 2. Rows against this copy's arrival baseline (shared write users have
2312    //    ALL access on library_nodes; the database pins user_id to the owner).
2313    //    A row this user changed sends only its changed fields, merged onto
2314    //    the row as it is on the cloud now, so the owner's newer edits to its
2315    //    other fields survive; a row the owner deleted meanwhile stays deleted.
2316    const ownerId = meta.user_id || folder.user_id;
2317    const flatNodes = flattenTreeForSync(children || [], libraryId);
2318    const currentIds = new Set();
2319    const inserts = [];
2320    const edits = [];
2321    for (const n of flatNodes) {
2322        if (currentIds.has(n.id)) continue;
2323        currentIds.add(n.id);
2324        const row = nodeToLibraryRow(n, libraryId, ownerId);
2325        const cur = _sharedRowFields(row);
2326        const prev = base.rows.get(row.id);
2327        if (!prev) { inserts.push({ row, cur }); continue; }
2328        const fields = {};
2329        for (const k of Object.keys(cur)) if (cur[k] !== prev[k]) fields[k] = row[k];
2330        if (Object.keys(fields).length > 0) edits.push({ id: row.id, row, fields, cur });
2331    }
2332    const fresh = new Map();
2333    for (let i = 0; i < edits.length; i += 100) {
2334        const ids = edits.slice(i, i + 100).map(e => e.id);
2335        const { data, error } = await supabase.from('library_nodes').select('*').eq('library_id', libraryId).in('id', ids);
2336        if (error) throw error;
2337        for (const r of data || []) fresh.set(r.id, r);
2338    }
2339    const now = new Date().toISOString();
2340    const writes = [];
2341    const nextFields = [];
2342    for (const e of edits) {
2343        const cloud = fresh.get(e.id);
2344        if (cloud) {
2345            const cloudTs = cloud.field_timestamps || {};
2346            const fieldTs = { ...cloudTs };
2347            for (const [k, t] of Object.entries(e.row.field_timestamps || {})) {
2348                if (!(k in cloudTs) || t > cloudTs[k]) fieldTs[k] = t;
2349            }
2350            writes.push({ ...cloud, ...e.fields, last_modified: now, field_timestamps: fieldTs });
2351        }
2352        nextFields.push([e.id, e.cur]);
2353    }
2354    for (const x of inserts) { writes.push(x.row); nextFields.push([x.row.id, x.cur]); }
2355    if (writes.length > 0) {
2356        const { error } = await supabase.from('library_nodes').upsert(writes);
2357        if (error) throw error;
2358    }
2359    for (const [id, f] of nextFields) base.rows.set(id, f);
2360
2361    // Rows this copy held and dropped. Never "every cloud row this copy
2362    // lacks": that deleted the rows the owner added after this copy loaded.
2363    const dropped = [...base.rows.keys()].filter(id => !currentIds.has(id));
2364    if (dropped.length > 0) {
2365        const { error } = await supabase.from('library_nodes').delete().eq('library_id', libraryId).in('id', dropped);
2366        if (error) throw error;
2367        for (const id of dropped) base.rows.delete(id);
2368    }
2369
2370    // 3. Library-level note pages, by the same rule.
2371    await _syncSharedLibraryNotes(libraryId, libraryNotes, ownerId, base.notes);
2372
2373    // 4. Final metadata touch — ensures Realtime notification fires AFTER nodes+notes are committed
2374    const { data: touchRows, error: touchErr } = await supabase.from('libraries').update({
2375        last_modified: new Date().toISOString(),
2376        last_modified_by: getPageWriterId()
2377    }).eq('id', libraryId).select('id');
2378    if (touchErr) throw touchErr;
2379    if (!touchRows || touchRows.length === 0) throw new Error('The library owner no longer gives you edit access.');
2380}
2381
2382export async function saveLibraryOrder(uid) {
2383    if (!requirePaidTier()) return;
2384    const promises = libraryData.map((folder, i) => {
2385        if (!folder || !folder.id) return Promise.resolve();
2386        return supabase
2387            .from('libraries')
2388            .update({ display_order: i, last_modified_by: getPageWriterId() })
2389            .eq('id', folder.id)
2390            .eq('user_id', uid);
2391    });
2392    await Promise.all(promises);
2393    _scheduleEntityDocWrite(uid); // 2A: order authority is the entity doc under cutover
2394}
2395
2396export async function deleteLibrary(uid, folderId) {
2397    if (!requirePaidTier()) return;
2398    // After any write of this library already asked for; later ones are
2399    // dropped (see _queueLibraryWrite).
2400    _deletedLibraryIds.add(folderId);
2401    try {
2402        return await _queueLibraryWrite(folderId, 'delete', [uid, folderId], _deleteLibraryNow);
2403    } catch (err) {
2404        _deletedLibraryIds.delete(folderId); // the library is still there; its saves must reach it
2405        throw err;
2406    }
2407}
2408
2409async function _deleteLibraryNow(uid, folderId) {
2410    // Delete library-level notes (not cascaded by FK)
2411    await supabase.from('notes').delete()
2412        .eq('user_id', uid).eq('owner_type', 'library').eq('owner_id', folderId);
2413
2414    // Delete library_node-level notes (owner_id = node id, not library id)
2415    const nodeRows = await fetchAllRows(
2416        (sel) => supabase.from('library_nodes').select('id', sel).eq('library_id', folderId)
2417    );
2418    const nodeIds = (nodeRows || []).map(r => r.id);
2419    if (nodeIds.length > 0) {
2420        await supabase.from('notes').delete()
2421            .eq('user_id', uid).eq('owner_type', 'library_node').in('owner_id', nodeIds);
2422    }
2423
2424    // Delete library (library_nodes cascade via FK)
2425    const { error } = await supabase
2426        .from('libraries')
2427        .delete()
2428        .eq('id', folderId)
2429        .eq('user_id', uid);
2430    if (error) { invalidateLibrariesCache(uid); throw error; }
2431
2432    // Same reason as saveLibrary: without this the deleted library reappears
2433    // on the next reload in this tab.
2434    syncLibrariesCache(uid, null, { removedId: folderId });
2435}
2436
2437// --- LIBRARY SHARING ---
2438
2439export async function setLibraryPublic(libraryId, uid, isPublic) {
2440    const { error } = await supabase
2441        .from('libraries')
2442        .update({ is_public: isPublic, last_modified: new Date().toISOString(), last_modified_by: getPageWriterId() })
2443        .eq('id', libraryId)
2444        .eq('user_id', uid);
2445    if (error) throw error;
2446    // Library writes that bypass saveLibrary must still keep the session cache
2447    // in step; a cached copy with the old flag would resurface on reload.
2448    invalidateLibrariesCache(uid);
2449}
2450
2451export async function getLibraryShares(libraryId) {
2452    const { data, error } = await supabase
2453        .from('library_shares')
2454        .select('*')
2455        .eq('library_id', libraryId)
2456        .order('created_at', { ascending: true });
2457    if (error) throw error;
2458    return data || [];
2459}
2460
2461export async function addLibraryShare(libraryId, ownerUid, email, permission = 'read') {
2462    let resolvedUserId = null;
2463    try {
2464        const { data } = await supabase.rpc('lookup_user_id_by_email', { lookup_email: email });
2465        if (data) resolvedUserId = data;
2466    } catch (_) { /* user not found */ }
2467
2468    const { data, error } = await supabase
2469        .from('library_shares')
2470        .upsert({
2471            library_id: libraryId,
2472            owner_id: ownerUid,
2473            shared_with_email: email,
2474            shared_with_user_id: resolvedUserId,
2475            permission
2476        }, { onConflict: 'library_id,shared_with_email' })
2477        .select()
2478        .single();
2479    if (error) throw error;
2480    return data;
2481}
2482
2483export async function updateSharePermission(shareId, permission) {
2484    const { error } = await supabase
2485        .from('library_shares')
2486        .update({ permission })
2487        .eq('id', shareId);
2488    if (error) throw error;
2489}
2490
2491export async function removeLibraryShare(shareId) {
2492    const { error } = await supabase
2493        .from('library_shares')
2494        .delete()
2495        .eq('id', shareId);
2496    if (error) throw error;
2497}
2498
2499export async function getMyLibraryAccess(libraryId, uid) {
2500    // Check ownership first
2501    const { data: lib } = await supabase
2502        .from('libraries')
2503        .select('user_id, is_public')
2504        .eq('id', libraryId)
2505        .single();
2506
2507    if (!lib) return { access: null, ownerUserId: null, viaShare: false };
2508    if (lib.user_id === uid) return { access: 'owner', ownerUserId: lib.user_id, viaShare: false };
2509
2510    // Check share record (RLS now matches by user_id OR email)
2511    if (uid) {
2512        const { data: share, error: shareErr } = await supabase
2513            .from('library_shares')
2514            .select('permission')
2515            .eq('library_id', libraryId)
2516            .maybeSingle();
2517        if (shareErr) console.warn('[getMyLibraryAccess] share query error:', shareErr.message);
2518        if (share) {
2519            console.log('[getMyLibraryAccess] share found, permission:', share.permission);
2520            return { access: share.permission, ownerUserId: lib.user_id, viaShare: true };
2521        }
2522    }
2523
2524    // Public access (no explicit invite — viaShare stays false so the caller
2525    // can distinguish invited guests from public visitors for media gating).
2526    if (lib.is_public) return { access: 'read', ownerUserId: lib.user_id, viaShare: false };
2527
2528    return { access: null, ownerUserId: lib.user_id, viaShare: false };
2529}
2530
2531// ─── User profiles ───────────────────────────────────
2532// Returns { user_name } for a given user ID. Caches in sessionStorage.
2533export async function getProfile(uid) {
2534    if (!uid) return null;
2535    const cached = getCached('profile_' + uid);
2536    if (cached !== null) return cached;
2537    const { data, error } = await supabase
2538        .from('profiles')
2539        .select('user_name')
2540        .eq('user_id', uid)
2541        .maybeSingle();
2542    if (error) { console.warn('[getProfile]', error.message); return null; }
2543    const profile = data ?? null;
2544    if (profile) setCache('profile_' + uid, profile);
2545    return profile;
2546}
2547
2548// ─── Subscription tier ────────────────────────────────
2549// Returns the user's subscription tier (1–4 for Pro tiers, null = free).
2550// Caches in sessionStorage to avoid repeated queries.
2551let cachedSubscriptionInfo = null;
2552
2553/** Guard for write operations: rejects if cached tier is 0 (free). */
2554function requirePaidTier() {
2555    if (cachedTier === null || cachedTier >= 1) return true; // null = not yet loaded, allow
2556    console.warn('[data.js] Write blocked: tier 0 (free)');
2557    return false;
2558}
2559let cachedTier = null;
2560
2561export async function getSubscriptionTier(uid) {
2562    if (!uid) return null;
2563    // Always query the DB if cachedSubscriptionInfo is not populated,
2564    // even if the tier itself is cached in sessionStorage. The trial
2565    // banner and other features depend on the full subscription info.
2566    const cached = getCached('sub_tier_' + uid);
2567    if (cached !== null && cachedSubscriptionInfo) return cached;
2568    const { data, error } = await supabase
2569        .from('subscriptions')
2570        .select('tier, status, cancel_at_period_end, current_period_end, billing_interval')
2571        .eq('user_id', uid)
2572        .maybeSingle();
2573    if (error) { console.warn('[getSubscriptionTier]', error.message); return null; }
2574    const tier = data?.tier ?? null;
2575    cachedTier = tier ?? 0;
2576    setCache('sub_tier_' + uid, tier);
2577    cachedSubscriptionInfo = data ? {
2578        status: data.status || 'active',
2579        cancelAtPeriodEnd: data.cancel_at_period_end || false,
2580        currentPeriodEnd: data.current_period_end || null,
2581        billingInterval: data.billing_interval || null,
2582    } : null;
2583    return tier;
2584}
2585
2586/** Returns cached subscription info (call getSubscriptionTier first). */
2587export function getSubscriptionInfo() {
2588    return cachedSubscriptionInfo;
2589}
2590
2591/**
2592 * Whether the user administers any organization (owner of an org, or an active
2593 * admin membership). Used to reveal the header "Organization" admin link.
2594 * RLS-safe: both queries only return rows the user is allowed to see.
2595 * @param {string} uid
2596 * @returns {Promise<boolean>}
2597 */
2598export async function isOrgAdmin(uid) {
2599    if (!uid) return false;
2600    try {
2601        const [ownedRes, memRes] = await Promise.all([
2602            supabase.from('organizations').select('id').eq('owner_user_id', uid).limit(1),
2603            supabase.from('organization_members').select('org_id').eq('user_id', uid).eq('role', 'admin').eq('status', 'active').limit(1),
2604        ]);
2605        return (ownedRes.data?.length > 0) || (memRes.data?.length > 0);
2606    } catch (_) { return false; }
2607}
2608
2609/**
2610 * Fetch the library owner's subscription tier via SECURITY DEFINER RPC.
2611 * RLS on the subscriptions table prevents guests from reading the owner's
2612 * tier directly, so this RPC verifies write access and returns the tier.
2613 * @param {string} libraryId
2614 * @returns {Promise<number>} owner's tier (0 if no access or no subscription)
2615 */
2616export async function getLibraryOwnerTier(libraryId) {
2617    const { data, error } = await supabase.rpc('get_library_owner_tier', { p_library_id: libraryId });
2618    if (error) { console.warn('[getLibraryOwnerTier]', error.message); return 0; }
2619    return data ?? 0;
2620}
2621
2622/**
2623 * Create a Stripe Checkout session and return the URL.
2624 * @param {string} priceId - Stripe Price ID for the selected plan
2625 * @param {{successUrl?: string, cancelUrl?: string}} [opts]
2626 * @returns {Promise<string>} Checkout URL to redirect to
2627 */
2628export async function createCheckoutSession(priceId, opts = {}) {
2629    const body = { priceId };
2630    if (opts.successUrl) body.successUrl = opts.successUrl;
2631    if (opts.cancelUrl) body.cancelUrl = opts.cancelUrl;
2632    const { data, error } = await supabase.functions.invoke('create-checkout', {
2633        body,
2634        headers: await authHeaders(),
2635    });
2636    if (error) throw error;
2637    if (data?.error) throw new Error(data.error);
2638    return data.url;
2639}
2640
2641/**
2642 * Create a Stripe Customer Portal session and return the URL.
2643 * @returns {Promise<string>} Portal URL to redirect to
2644 */
2645export async function createPortalSession() {
2646    const { data, error } = await supabase.functions.invoke('create-portal', {
2647        body: {},
2648        headers: await authHeaders(),
2649    });
2650    if (error) throw error;
2651    if (data?.error) throw new Error(data.error);
2652    return data.url;
2653}
2654
2655/**
2656 * Subscribe to real-time changes on the user's subscription row.
2657 * Fires callback with the new tier whenever it changes.
2658 */
2659export function subscribeToSubscriptionChanges(uid, callback) {
2660    const channel = supabase
2661        .channel('portal-subscription')
2662        .on(
2663            'postgres_changes',
2664            {
2665                event: '*',
2666                schema: 'public',
2667                table: 'subscriptions',
2668                filter: `user_id=eq.${uid}`
2669            },
2670            (payload) => {
2671                if (payload.eventType === 'DELETE') {
2672                    cachedSubscriptionInfo = null;
2673                    cachedTier = 0;
2674                    try { sessionStorage.removeItem(CACHE_PREFIX + 'sub_tier_' + uid); } catch {}
2675                    callback(0);
2676                    return;
2677                }
2678                const row = payload.new;
2679                if (!row) return;
2680                const tier = row.tier ?? 0;
2681                cachedTier = tier;
2682                setCache('sub_tier_' + uid, tier);
2683                cachedSubscriptionInfo = {
2684                    status: row.status || 'active',
2685                    cancelAtPeriodEnd: row.cancel_at_period_end || false,
2686                    currentPeriodEnd: row.current_period_end || null,
2687                    billingInterval: row.billing_interval || null,
2688                };
2689                callback(tier);
2690            }
2691        )
2692        .subscribe();
2693
2694    return () => supabase.removeChannel(channel);
2695}
2696
2697// ─── AI semantic search ──────────────────────────────
2698// `contextType` (U4f, 2026-09-03): the EF labels the context block by it —
2699// 'sessions' for /portal/ai's AI session search (digests, the same shape the
2700// extension and the Bridge send), absent for the tab tree as before.
2701export async function performAISearch(query, treeContext, contextType) {
2702    const body = { query, treeContext };
2703    if (contextType) body.contextType = contextType;
2704    const { data, error } = await supabase.functions.invoke('semantic-search', {
2705        body,
2706        headers: await authHeaders(),
2707    });
2708    if (error) {
2709        const res = error.context;
2710        if (res) {
2711            try {
2712                const body = typeof res.json === 'function' ? await res.json()
2713                           : typeof res.text === 'function' ? JSON.parse(await res.text())
2714                           : null;
2715                if (body?.code || body?.error) {
2716                    const err = new Error(body.error || error.message);
2717                    err.code = body.code;
2718                    attachCreditContext(err, body);
2719                    throw err;
2720                }
2721            } catch (e) { if (e.code) throw e; }
2722        }
2723        throw error;
2724    }
2725    if (data?.error) {
2726        const err = new Error(data.error);
2727        err.code = data.code;
2728        attachCreditContext(err, data);
2729        throw err;
2730    }
2731    return data;
2732}
2733
2734// A credit refusal (LIMIT_REACHED / INSUFFICIENT_REMAINING_CREDITS) carries
2735// the pool numbers; copy them onto the thrown error so the shared toolbar's
2736// credits-low line and the buy-credits modal can quote them. `used` /
2737// `limit` are the legacy names the toolbar reads (this file used to copy
2738// fields the EF never sent); the camelCase set is what the EFs send since
2739// Phase 4.3, plus monthlyCap / bonusCredits / resetsAt since ai-todo #70.
2740const CREDIT_CONTEXT_FIELDS = ['creditsUsed', 'creditsLimit', 'remainingCredits', 'estimatedCredits', 'monthlyCap', 'bonusCredits', 'resetsAt'];
2741function attachCreditContext(err, data) {
2742    if (!data || typeof data !== 'object') return err;
2743    const src = (data.error && typeof data.error === 'object' && data.error.context) ? data.error.context : data;
2744    for (const k of CREDIT_CONTEXT_FIELDS) {
2745        if (src[k] !== undefined) err[k] = src[k];
2746    }
2747    err.used = err.creditsUsed;
2748    err.limit = err.creditsLimit;
2749    return err;
2750}
2751
2752// ─── AI credit pool + top-ups (ai-todo #70) ─────────────────
2753// ONE home: public.ai_credit_pool() answers for the signed-in user with the
2754// same arithmetic every edge function bills by. Shape (camelCase):
2755//   { tier, tokensPerCredit, monthlyCap, windowStart, windowEnd,
2756//     windowUsedTokens, windowUsedCredits, bonusSpentThisWindow,
2757//     bonusBalance, creditsLimit, remainingCredits,
2758//     packRates: [{minCredits, maxCredits, centsPer100}], packMaxCredits,
2759//     embedTokensPerCredit, embedTokensPerGb, embedAllowanceTokens,
2760//     embedAllowanceTiers: [{tier, gb, tokens}], embedCeilingTokens,
2761//     embedTokensLifetime, embedTokensWindow,
2762//     embedQuote?: {embedTokens, chargedEmbedTokens, poolTokens, credits} }
2763// Pass { embedTokens } to get embedQuote back for that many true embed
2764// tokens (S20d); omit it for the plain pool with no embedQuote key.
2765// Returns null when signed out; throws when the read fails.
2766export async function getAICreditPool(opts) {
2767    const { data: { session } } = await supabase.auth.getSession();
2768    if (!session) return null;
2769    const { data, error } = (opts && Number.isFinite(opts.embedTokens))
2770        ? await supabase.rpc('ai_credit_pool', { p_embed_tokens: Math.max(0, Math.floor(opts.embedTokens)) })
2771        : await supabase.rpc('ai_credit_pool');
2772    if (error) throw new Error(error.message || 'credit pool read failed');
2773    if (!data || typeof data !== 'object') return null;
2774    return data;
2775}
2776
2777// Start a Stripe Checkout for `credits` (a positive multiple of 100).
2778// Resolves to { url, credits, amountCents }. Raw fetch with explicit headers
2779// (the project's EF convention); the server prices and re-verifies.
2780export async function createTopupCheckout(credits, opts = {}) {
2781    const headers = await authHeaders();
2782    if (!headers) {
2783        const err = new Error('Sign in to buy credits.');
2784        err.code = 'AUTH_REQUIRED';
2785        throw err;
2786    }
2787    const body = { topupCredits: credits };
2788    if (opts.successUrl) body.successUrl = opts.successUrl;
2789    if (opts.cancelUrl) body.cancelUrl = opts.cancelUrl;
2790    const res = await fetch(`${SUPABASE_URL}/functions/v1/create-checkout`, {
2791        method: 'POST',
2792        headers: { ...headers, apikey: SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
2793        body: JSON.stringify(body),
2794    });
2795    let data = null;
2796    try { data = await res.json(); } catch (_) {}
2797    if (!res.ok || !data || data.error || !data.url) {
2798        const err = new Error((data && data.error) || `Checkout failed (${res.status})`);
2799        err.code = (data && data.code) || `HTTP_${res.status}`;
2800        throw err;
2801    }
2802    return data;
2803}
2804
2805// Lazy backfill: populate shared_with_user_id for shares matched by email.
2806// Call AFTER access level is fully determined — never in the critical path.
2807//
2808// This is what makes a share survive the owner changing their email address.
2809// RLS grants shared access on `shared_with_user_id = auth.uid() OR
2810// shared_with_email = <jwt email>`, and shared_with_user_id is nullable, so
2811// until it is stamped the address string is the only thing granting access.
2812// See account-credentials-change-spec.md §4.1.
2813//
2814// Latches per user id so every entry point can call it unconditionally on
2815// sign-in without stacking duplicate round trips. Keyed on the id rather than a
2816// bare flag so signing out and back in as someone else still claims.
2817//
2818// The latch is set only on a genuinely clean call. supabase.rpc() resolves with
2819// { data, error } rather than throwing, so awaiting it and latching afterwards
2820// would mark a 401 or a 500 as done and never retry it for the life of this
2821// page — leaving the shares email-only, which is exactly what this exists to
2822// prevent. The error has to be inspected, not just caught.
2823let _pendingSharesResolvedFor = null;
2824export async function resolvePendingShares(userId) {
2825    if (userId && _pendingSharesResolvedFor === userId) return;
2826    try {
2827        const { error } = await supabase.rpc('resolve_my_pending_shares');
2828        if (error) return;
2829        _pendingSharesResolvedFor = userId || null;
2830    } catch (_) {}
2831}
2832
2833// --- LIBRARY SNAPSHOTS (Supabase Storage) ---
2834
2835const SNAPSHOT_BUCKET = 'library-snapshots';
2836
2837/**
2838 * Publish a static JSON snapshot of a library to Supabase Storage.
2839 * The snapshot is publicly readable and serves read-only viewers
2840 * instead of hitting the database + real-time subscriptions.
2841 */
2842export async function publishLibrarySnapshot(libraryId, uid, folder) {
2843    const snapshot = {
2844        id: folder.id,
2845        title: folder.title || '',
2846        description: folder.description || '',
2847        children: folder.children || [],
2848        notes: folder.notes || [],
2849        publishedAt: new Date().toISOString()
2850    };
2851    const blob = new Blob([JSON.stringify(snapshot)], { type: 'application/json' });
2852    const { error: uploadErr } = await supabase.storage
2853        .from(SNAPSHOT_BUCKET)
2854        .upload(`${libraryId}.json`, blob, { upsert: true, contentType: 'application/json' });
2855    if (uploadErr) throw uploadErr;
2856
2857    // Update published_at timestamp in the database
2858    const { error: dbErr } = await supabase
2859        .from('libraries')
2860        .update({ published_at: snapshot.publishedAt })
2861        .eq('id', libraryId)
2862        .eq('user_id', uid);
2863    if (dbErr) console.warn('[publishLibrarySnapshot] failed to update published_at:', dbErr.message);
2864    // Bypasses saveLibrary, so the session cache must not keep the old state.
2865    invalidateLibrariesCache(uid);
2866}
2867
2868/**
2869 * Delete a library's public snapshot from Supabase Storage.
2870 */
2871export async function deleteLibrarySnapshot(libraryId, uid) {
2872    const { error: removeErr } = await supabase.storage
2873        .from(SNAPSHOT_BUCKET)
2874        .remove([`${libraryId}.json`]);
2875    if (removeErr) console.warn('[deleteLibrarySnapshot] remove failed:', removeErr.message);
2876
2877    // Clear published_at timestamp
2878    const { error: dbErr } = await supabase
2879        .from('libraries')
2880        .update({ published_at: null })
2881        .eq('id', libraryId)
2882        .eq('user_id', uid);
2883    if (dbErr) console.warn('[deleteLibrarySnapshot] failed to clear published_at:', dbErr.message);
2884    // Bypasses saveLibrary, so the session cache must not keep the old state.
2885    invalidateLibrariesCache(uid);
2886}
2887
2888/**
2889 * Fetch a library's public snapshot from Supabase Storage.
2890 * Returns the parsed folder object ready for rendering, or null if not found.
2891 *
2892 * Defense in depth: verifies the library's current `is_public` flag before
2893 * trusting the snapshot. The `"Public read access"` RLS policy on `libraries`
2894 * returns no rows for private libraries, so a stale snapshot left in storage
2895 * after a public→private toggle cannot leak. Without this check, anyone with
2896 * the library ID could still read an orphaned snapshot from the public bucket.
2897 */
2898export async function fetchLibrarySnapshot(libraryId) {
2899    const { data: lib } = await supabase
2900        .from('libraries')
2901        .select('id, is_public')
2902        .eq('id', libraryId)
2903        .maybeSingle();
2904    if (!lib || !lib.is_public) return null;
2905
2906    const { data } = supabase.storage.from(SNAPSHOT_BUCKET).getPublicUrl(`${libraryId}.json`);
2907    if (!data?.publicUrl) return null;
2908    try {
2909        // Append cache-buster to avoid stale CDN responses
2910        const res = await fetch(data.publicUrl + '?t=' + Date.now());
2911        if (!res.ok) return null;
2912        return await res.json();
2913    } catch {
2914        return null;
2915    }
2916}
2917
2918// --- NOTES ---
2919
2920// Migrate old object format { text: '...' } to array format [{ id, title, content }]
2921function migrateNotesData(raw) {
2922    if (Array.isArray(raw)) return raw;
2923    if (raw && typeof raw === 'object' && raw.text) {
2924        return [{
2925            id: 'note-' + Date.now() + '-' + Math.floor(Math.random() * 1000),
2926            title: 'Notes',
2927            content: raw.text
2928        }];
2929    }
2930    return [];
2931}
2932
2933// Load main tree notes for the device currently selected via the device
2934// switcher. Main tree notes are per-device — each install has its own set —
2935// so the portal always views a specific device's notes, never a merged view.
2936// Returns [] when no device is selected yet (initial load before loadTree
2937// has picked one) or when the selected device has no notes yet.
2938export async function loadNotes(uid) {
2939    if (!selectedDeviceId) {
2940        globalNotes = [];
2941        _notesLoadedForDeviceId = null;
2942        return globalNotes;
2943    }
2944    // The device these notes are read for: the stamp below must name the
2945    // device whose notes end up in memory, even if the selection moves on
2946    // while the read is in flight (saveNotes then refuses, as it should).
2947    const deviceId = selectedDeviceId;
2948    const notes = await _loadFreshOrCached(uid, 'notes_' + uid + '_' + deviceId, async () => {
2949        const { data, error } = await supabase
2950            .from('notes')
2951            .select('*')
2952            .eq('owner_type', 'global')
2953            .eq('owner_id', uid)
2954            .eq('user_id', uid)
2955            .eq('device_id', deviceId)
2956            .order('sort_order', { ascending: true });
2957        if (error) {
2958            if (error.code === 'PGRST116') return [];
2959            throw error;
2960        }
2961        return (data || []).map(r => ({ id: r.id, title: r.title, content: r.content }));
2962    }, v => !Array.isArray(v) || v.length === 0);
2963
2964    globalNotes = notes;
2965    _notesLoadedForDeviceId = deviceId;
2966    return globalNotes;
2967}
2968
2969export function subscribeToNotes(uid, callback) {
2970    let debounceTimer = null;
2971
2972    const channel = supabase
2973        .channel('portal-notes')
2974        .on(
2975            'postgres_changes',
2976            {
2977                event: '*',
2978                schema: 'public',
2979                table: 'notes',
2980                filter: `user_id=eq.${uid}`
2981            },
2982            (payload) => {
2983                // Only handle main tree notes for the currently-selected device.
2984                // Reads selectedDeviceId at event time, so device switches take
2985                // effect for future events without re-subscribing.
2986                const row = payload.new || payload.old;
2987                if (row?.owner_type !== 'global') return;
2988                if (!selectedDeviceId || row?.device_id !== selectedDeviceId) return;
2989                if (row?.last_modified_by === getPageWriterId()) return;
2990
2991                // Debounce — a full notes save may fire multiple row events
2992                clearTimeout(debounceTimer);
2993                debounceTimer = setTimeout(async () => {
2994                    if (!selectedDeviceId) return;
2995                    const targetDeviceId = selectedDeviceId;
2996                    const { data } = await supabase.from('notes').select('*')
2997                        .eq('owner_type', 'global').eq('owner_id', uid).eq('user_id', uid)
2998                        .eq('device_id', targetDeviceId)
2999                        .order('sort_order', { ascending: true });
3000                    // If the device switched mid-debounce, drop this stale payload
3001                    if (selectedDeviceId !== targetDeviceId) return;
3002                    globalNotes = (data || []).map(r => ({ id: r.id, title: r.title, content: r.content }));
3003                    _notesLoadedForDeviceId = targetDeviceId;
3004                    setCache('notes_' + uid + '_' + targetDeviceId, globalNotes);
3005                    callback(globalNotes);
3006                }, 500);
3007            }
3008        )
3009        .subscribe();
3010
3011    return () => { clearTimeout(debounceTimer); supabase.removeChannel(channel); };
3012}
3013
3014export async function saveNotes(uid) {
3015    if (!requirePaidTier()) return;
3016    if (!selectedDeviceId) return;  // no device selected, can't attribute notes
3017    // Same guard as the tree save: a half-failed device switch can leave one
3018    // device's notes in memory with another device selected; syncNotesArray's
3019    // set-difference prune would then destroy the selected device's notes.
3020    if (_notesLoadedForDeviceId !== selectedDeviceId) {
3021        const err = new Error(`Refusing to save notes: memory holds device ${_notesLoadedForDeviceId}'s notes but ${selectedDeviceId} is selected`);
3022        _dispatchSaveError(err, 'saveNotes device stamp');
3023        return;
3024    }
3025    try {
3026        await syncNotesArray(globalNotes, 'global', uid, uid, selectedDeviceId);
3027        // Keep the session cache in step (the realtime handler filters out our
3028        // own events, so nothing else would): without this, editing Main Notes
3029        // and reloading the same tab showed pre-edit content.
3030        const cacheKey = 'notes_' + uid + '_' + selectedDeviceId;
3031        if (!setCache(cacheKey, globalNotes)) invalidateCache(cacheKey);
3032    } catch (err) {
3033        _dispatchSaveError(err, 'saveNotes');
3034        throw err;
3035    }
3036}
3037
3038let saveNotesTimer = null;
3039export function debouncedSaveNotes(uid) {
3040    if (!requirePaidTier()) return;
3041    clearTimeout(saveNotesTimer);
3042    saveNotesTimer = setTimeout(() => saveNotes(uid), 500);
3043}
3044
3045// Synchronous flush for beforeunload — if a debounced save is pending, fire a
3046// keepalive bulk upsert of globalNotes so the request survives page unload.
3047// Does not handle deletions (fire-and-forget, no time to resolve the previousIds
3048// round-trip). Safe to call even if no save is pending.
3049export function flushPendingNotesKeepalive(uid) {
3050    if (!saveNotesTimer) return;
3051    clearTimeout(saveNotesTimer);
3052    saveNotesTimer = null;
3053    if (!selectedDeviceId) return;  // no device selected, can't attribute notes
3054    // Stamp mismatch: wrong device's notes in memory (see saveNotes). No
3055    // toast here — the page is unloading — just refuse the write.
3056    if (_notesLoadedForDeviceId !== selectedDeviceId) return;
3057    const notes = Array.isArray(globalNotes) ? globalNotes : [];
3058    if (notes.length === 0) return;
3059    const upserts = [];
3060    for (let i = 0; i < notes.length; i++) {
3061        const n = notes[i];
3062        if (!n || !n.id) continue;
3063        upserts.push({
3064            id: n.id,
3065            user_id: uid,
3066            owner_type: 'global',
3067            owner_id: uid,
3068            device_id: selectedDeviceId,
3069            title: n.title || '',
3070            content: n.content || '',
3071            sort_order: i,
3072            last_modified: new Date().toISOString(),
3073            last_modified_by: getPageWriterId()
3074        });
3075    }
3076    if (upserts.length > 0) restUpsertKeepalive('notes', upserts);
3077}
3078
3079// --- THEME PREFERENCES (localStorage, per-device) ---
3080export function loadThemePreferences() {
3081    return JSON.parse(localStorage.getItem('pinako_theme') || 'null');
3082}
3083
3084export function saveThemePreferences(prefs) {
3085    localStorage.setItem('pinako_theme', JSON.stringify(prefs));
3086}
3087
3088// --- CUSTOM THEMES (Supabase, synced across devices) ---
3089
3090// Map extension format → portal format
3091function extensionThemeToPortal(t) {
3092    return {
3093        name: t.name,
3094        isDark: t.isDark !== false,
3095        colors: {
3096            '--accent-color': t.placementColor,
3097            '--accent2-color': t.parentNodeButtonColor,
3098            '--line-color': t.treeColor,
3099            '--bg-color': t.bgColor,
3100            '--item-bg': t.nodeBgColor,
3101            '--row-active-bg': t.rowActiveBgColor,
3102            '--text-color': t.nodeTextColor
3103        }
3104    };
3105}
3106
3107// Map portal format → extension format
3108function portalThemeToExtension(t) {
3109    const c = t.colors || {};
3110    return {
3111        name: t.name,
3112        isDark: t.isDark !== false,
3113        bgColor: c['--bg-color'] || '',
3114        nodeBgColor: c['--item-bg'] || '',
3115        nodeTextColor: c['--text-color'] || '',
3116        treeColor: c['--line-color'] || '',
3117        parentNodeButtonColor: c['--accent2-color'] || '',
3118        placementColor: c['--accent-color'] || '',
3119        rowActiveBgColor: c['--row-active-bg'] || ''
3120    };
3121}
3122
3123export async function loadCustomThemes(uid) {
3124    return _loadFreshOrCached(uid, 'themes_' + uid, async () => {
3125        const { data, error } = await supabase
3126            .from('custom_themes')
3127            .select('*')
3128            .eq('user_id', uid)
3129            .single();
3130        if (error) {
3131            if (error.code === 'PGRST116') return [];
3132            throw error;
3133        }
3134        return (data.themes || []).map(extensionThemeToPortal);
3135    }, v => !Array.isArray(v) || v.length === 0);
3136}
3137
3138export function subscribeToCustomThemes(uid, callback) {
3139    const channel = supabase
3140        .channel('portal-themes')
3141        .on(
3142            'postgres_changes',
3143            {
3144                event: '*',
3145                schema: 'public',
3146                table: 'custom_themes',
3147                filter: `user_id=eq.${uid}`
3148            },
3149            (payload) => {
3150                const data = payload.new;
3151                if (!data) return;
3152                if (data.last_modified_by === getPageWriterId()) return;
3153                const themes = (data.themes || []).map(extensionThemeToPortal);
3154                setCache('themes_' + uid, themes);
3155                callback(themes);
3156            }
3157        )
3158        .subscribe();
3159
3160    return () => supabase.removeChannel(channel);
3161}
3162
3163export async function saveCustomThemes(uid, portalThemes) {
3164    const extensionFormat = portalThemes.map(portalThemeToExtension);
3165    const { error } = await supabase
3166        .from('custom_themes')
3167        .upsert({
3168            user_id: uid,
3169            themes: extensionFormat,
3170            last_modified: new Date().toISOString(),
3171            last_modified_by: getPageWriterId()
3172        });
3173    if (error) throw error;
3174    // Cache holds PORTAL format, same as loadCustomThemes writes.
3175    if (!setCache('themes_' + uid, portalThemes)) invalidateCache('themes_' + uid);
3176}
3177
3178// ─── User Preferences ───────────────────────────
3179
3180const DEFAULT_PREFERENCES = {
3181    showGlobalNotes: true,
3182    showDates: true,
3183    showIndentOutdent: true,
3184    openTabOnMode: 'double',
3185    dragDropMode: 'rowSibling'
3186};
3187
3188export async function loadUserPreferences(uid) {
3189    const prefs = await _loadFreshOrCached(uid, 'prefs_' + uid, async () => {
3190        const { data, error } = await supabase
3191            .from('user_preferences')
3192            .select('*')
3193            .eq('user_id', uid)
3194            .single();
3195        if (error) {
3196            if (error.code === 'PGRST116') return null;   // no row yet: the defaults
3197            throw error;
3198        }
3199        return { ...DEFAULT_PREFERENCES, ...(data.preferences || {}) };
3200    }, v => v == null);
3201    return prefs ?? { ...DEFAULT_PREFERENCES };
3202}
3203
3204export async function saveUserPreferences(uid, preferences) {
3205    clearTimeout(savePrefsTimer);
3206    const { error } = await supabase
3207        .from('user_preferences')
3208        .upsert({
3209            user_id: uid,
3210            preferences,
3211            last_modified: new Date().toISOString()
3212        });
3213    if (error) throw error;
3214    // Same defaults-merged shape loadUserPreferences caches.
3215    const merged = { ...DEFAULT_PREFERENCES, ...(preferences || {}) };
3216    if (!setCache('prefs_' + uid, merged)) invalidateCache('prefs_' + uid);
3217}
3218
3219let savePrefsTimer = null;
3220export function debouncedSaveUserPreferences(uid, preferences) {
3221    if (!requirePaidTier()) return;
3222    clearTimeout(savePrefsTimer);
3223    savePrefsTimer = setTimeout(() => saveUserPreferences(uid, preferences), 500);
3224}
3225
3226// ─── Library Edit Logs ──────────────────────────────────────
3227
3228export async function fetchEditLogs(libraryId, { limit = 50, before = null } = {}) {
3229    let query = supabase
3230        .from('library_edit_logs')
3231        .select('*')
3232        .eq('library_id', libraryId)
3233        .order('timestamp', { ascending: false })
3234        .limit(limit);
3235    if (before) query = query.lt('timestamp', before);
3236    const { data, error } = await query;
3237    if (error) throw error;
3238    return data;
3239}
3240
3241export function subscribeToEditLogs(libraryId, callback) {
3242    const channel = supabase
3243        .channel(`edit-logs-${libraryId}`)
3244        .on('postgres_changes',
3245            { event: 'INSERT', schema: 'public', table: 'library_edit_logs', filter: `library_id=eq.${libraryId}` },
3246            (payload) => callback(payload.new)
3247        )
3248        .subscribe();
3249    return () => supabase.removeChannel(channel);
3250}
3251
3252export async function hasWriteShares(libraryId) {
3253    const { data, error } = await supabase
3254        .from('library_shares')
3255        .select('id')
3256        .eq('library_id', libraryId)
3257        .eq('permission', 'write')
3258        .limit(1);
3259    if (error) throw error;
3260    return data && data.length > 0;
3261}
3262
3263export async function writeEditLog(logEntry) {
3264    try {
3265        await writeWithOfflineFallback('library_edit_logs', 'insert', logEntry);
3266    } catch (err) {
3267        _dispatchSaveError(err, 'writeEditLog');
3268        throw err;
3269    }
3270}
3271
3272// ─── Media Upload (Tier 2+) ────────────────────────────────
3273
3274const MEDIA_LIMITS = {
3275    0: 0, 1: 0,
3276    2: 500 * 1024 * 1024,        // 500 MB
3277    3: 2 * 1024 * 1024 * 1024,   // 2 GB
3278    4: 10 * 1024 * 1024 * 1024,  // 10 GB
3279};
3280const MEDIA_FILE_LIMIT_RATIO = 0.4;
3281const MEDIA_BUCKET = 'note-media';
3282const MEDIA_CDN_BASE = 'https://media.pinako.pro';
3283
3284export function getMediaLimit(tier) {
3285    return MEDIA_LIMITS[tier] || 0;
3286}
3287
3288export function getMediaFileLimit(tier) {
3289    return Math.floor(getMediaLimit(tier) * MEDIA_FILE_LIMIT_RATIO);
3290}
3291
3292export async function getMediaUsage(uid) {
3293    const { data, error } = await supabase
3294        .from('note_media')
3295        .select('size_bytes')
3296        .eq('user_id', uid)
3297        .is('trashed_at', null);
3298    if (error) throw error;
3299    return (data || []).reduce((sum, r) => sum + r.size_bytes, 0);
3300}
3301
3302export async function uploadNoteMedia(file, tier, onProgress) {
3303    const { data: { session } } = await supabase.auth.getSession();
3304    if (!session) throw new Error('Not authenticated');
3305    const userId = session.user.id;
3306    const token = session.access_token;
3307
3308    const limit = getMediaLimit(tier);
3309    const fileLimit = getMediaFileLimit(tier);
3310
3311    if (limit === 0) throw new Error('Media upload not available on your plan. Upgrade to upload larger files.');
3312    if (!file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/'))
3313        throw new Error('Only image, video, and audio files are supported');
3314    if (file.size > fileLimit) {
3315        const maxMB = Math.round(fileLimit / 1024 / 1024);
3316        throw new Error(`File too large (max ${maxMB} MB). Upgrade to upload larger files.`);
3317    }
3318
3319    const used = await getMediaUsage(userId);
3320    if (used + file.size > limit) throw new Error('Storage quota exceeded. Upgrade to upload larger files.');
3321
3322    const ext = file.name.split('.').pop() || 'bin';
3323    const filePath = `${userId}/${crypto.randomUUID()}.${ext}`;
3324    const uploadUrl = `${supabase.supabaseUrl}/storage/v1/object/${MEDIA_BUCKET}/${filePath}`;
3325
3326    const publicUrl = await new Promise((resolve, reject) => {
3327        const xhr = new XMLHttpRequest();
3328        xhr.open('POST', uploadUrl);
3329        xhr.setRequestHeader('Authorization', `Bearer ${token}`);
3330        xhr.setRequestHeader('Content-Type', file.type);
3331
3332        if (typeof onProgress === 'function') {
3333            xhr.upload.onprogress = (e) => {
3334                if (e.lengthComputable) onProgress(e.loaded / e.total, xhr);
3335            };
3336        }
3337
3338        xhr.onload = () => {
3339            if (xhr.status >= 200 && xhr.status < 300) {
3340                if (MEDIA_CDN_BASE) {
3341                    resolve(`${MEDIA_CDN_BASE}/${filePath}`);
3342                } else {
3343                    const { data } = supabase.storage.from(MEDIA_BUCKET).getPublicUrl(filePath);
3344                    resolve(data.publicUrl);
3345                }
3346            } else {
3347                reject(new Error(`Upload failed (${xhr.status})`));
3348            }
3349        };
3350        xhr.onerror = () => reject(new Error('Upload failed — check your connection'));
3351        xhr.onabort = () => reject(new Error('Upload cancelled'));
3352        xhr.send(file);
3353    });
3354
3355    const { error: dbErr } = await supabase.from('note_media').insert({
3356        user_id: userId,
3357        file_path: filePath,
3358        file_name: file.name,
3359        size_bytes: file.size,
3360        mime_type: file.type
3361    });
3362    if (dbErr) throw dbErr;
3363
3364    return publicUrl;
3365}
3366
3367export async function deleteNoteMedia(filePath, uid) {
3368    const { error: storageErr } = await supabase.storage.from(MEDIA_BUCKET).remove([filePath]);
3369    if (storageErr) console.warn('[deleteNoteMedia] storage remove failed:', storageErr.message);
3370    const { error: dbErr } = await supabase.from('note_media').delete().match({ user_id: uid, file_path: filePath });
3371    if (dbErr) console.warn('[deleteNoteMedia] db delete failed:', dbErr.message);
3372}
3373
3374export async function listNoteMedia(uid) {
3375    const { data, error } = await supabase
3376        .from('note_media')
3377        .select('file_path, file_name, size_bytes, mime_type, created_at, node_id, library_id, note_title, trashed_at')
3378        .eq('user_id', uid)
3379        .order('created_at', { ascending: false });
3380    if (error) throw error;
3381    return data || [];
3382}
3383
3384export async function deleteNoteMediaBatch(filePaths, uid) {
3385    const { error: storageErr } = await supabase.storage.from(MEDIA_BUCKET).remove(filePaths);
3386    if (storageErr) console.warn('[deleteNoteMediaBatch] storage remove failed:', storageErr.message);
3387    const { error: dbErr } = await supabase.from('note_media').delete()
3388        .eq('user_id', uid).in('file_path', filePaths);
3389    if (dbErr) console.warn('[deleteNoteMediaBatch] db delete failed:', dbErr.message);
3390}
3391
3392export async function emptyMediaTrash(uid) {
3393    const { data, error: fetchErr } = await supabase.from('note_media')
3394        .select('file_path').eq('user_id', uid).not('trashed_at', 'is', null);
3395    if (fetchErr) { console.warn('[emptyMediaTrash]', fetchErr.message); return; }
3396    const paths = (data || []).map(r => r.file_path);
3397    if (paths.length === 0) return;
3398    const { error: storageErr } = await supabase.storage.from(MEDIA_BUCKET).remove(paths);
3399    if (storageErr) console.warn('[emptyMediaTrash] storage remove:', storageErr.message);
3400    const { error: dbErr } = await supabase.from('note_media').delete()
3401        .eq('user_id', uid).not('trashed_at', 'is', null);
3402    if (dbErr) console.warn('[emptyMediaTrash] db delete:', dbErr.message);
3403}
3404
3405export async function purgeOldMediaTrash(uid) {
3406    const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
3407    const { data, error: fetchErr } = await supabase.from('note_media')
3408        .select('file_path').eq('user_id', uid).lt('trashed_at', cutoff);
3409    if (fetchErr) { console.warn('[purgeOldMediaTrash]', fetchErr.message); return; }
3410    const paths = (data || []).map(r => r.file_path);
3411    if (paths.length === 0) return;
3412    const { error: storageErr } = await supabase.storage.from(MEDIA_BUCKET).remove(paths);
3413    if (storageErr) console.warn('[purgeOldMediaTrash] storage remove:', storageErr.message);
3414    const { error: dbErr } = await supabase.from('note_media').delete()
3415        .eq('user_id', uid).lt('trashed_at', cutoff);
3416    if (dbErr) console.warn('[purgeOldMediaTrash] db delete:', dbErr.message);
3417}
3418
3419// --- LIBRARY GROUPS ---
3420
3421export let libraryGroupData = [];
3422export let libraryPanelOrder = [];
3423
3424export function setLibraryGroupData(data) { libraryGroupData = data; }
3425export function setLibraryPanelOrder(order) { libraryPanelOrder = order; }
3426
3427// Whether this page holds a panel order it actually read (or was sent by
3428// realtime). After a failed read the panel shows a default order, and saving
3429// that back would replace the user's real arrangement, so saveLibraryPanelOrder
3430// and the entity doc's panel_order wait until an order has been read.
3431let _panelOrderKnown = false;
3432
3433export async function loadLibraryGroups(uid) {
3434    await ensureEntityOrder(uid);   // no-op if loadLibraries already fetched it
3435
3436    let orderUnread = false;
3437    const loaded = await _loadFreshOrCached(uid, 'library_groups_' + uid, async () => {
3438        const [groupsResult, orderResult] = await Promise.all([
3439            supabase.from('library_groups').select('*').eq('user_id', uid).order('display_order', { ascending: true }),
3440            supabase.from('library_panel_order').select('*').eq('user_id', uid).single()
3441        ]);
3442        if (groupsResult.error) throw groupsResult.error;
3443        // A failed order read is not "no order" (PGRST116, no row, is). With a
3444        // cached copy, let it stand in whole; without one, show the groups in
3445        // default order as before, but never save that order (see above).
3446        if (orderResult.error && orderResult.error.code !== 'PGRST116') {
3447            if (getCached('library_groups_' + uid) !== null) throw orderResult.error;
3448            orderUnread = true;
3449        }
3450        return {
3451            groups: (groupsResult.data || []).map(row => ({
3452                id: row.id,
3453                type: 'library-group',
3454                title: row.title || '',
3455                description: row.description || '',
3456                libraryIds: row.library_ids || [],
3457                notes: row.notes || [],
3458                createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now()
3459            })),
3460            panelOrder: orderResult.data?.panel_order || []
3461        };
3462    }, v => !v || ((v.groups || []).length === 0 && (v.panelOrder || []).length === 0));
3463
3464    // A default order stands in for an unread one: never cache it as the order.
3465    if (orderUnread) invalidateCache('library_groups_' + uid);
3466    _panelOrderKnown = !orderUnread;
3467    libraryGroupData = _sortGroupsByEntityOrder(loaded.groups || []);
3468    libraryPanelOrder = loaded.panelOrder || [];
3469    return { groups: libraryGroupData, panelOrder: libraryPanelOrder };
3470}
3471
3472export async function saveLibraryGroup(uid, group, displayOrder = 0) {
3473    if (!requirePaidTier()) return;
3474    const { error } = await supabase
3475        .from('library_groups')
3476        .upsert({
3477            id: group.id,
3478            user_id: uid,
3479            title: group.title || '',
3480            description: group.description || '',
3481            library_ids: group.libraryIds || [],
3482            notes: group.notes || [],
3483            display_order: displayOrder,
3484            last_modified: new Date().toISOString(),
3485            last_modified_by: getPageWriterId()
3486        });
3487    if (error) throw error;
3488    _scheduleEntityDocWrite(uid); // 2A: in-group membership order → entity doc
3489    // Same idiom the realtime handlers use for remote group events: drop the
3490    // cache so the next load refetches, rather than rebuilding it here.
3491    invalidateCache('library_groups_' + uid);
3492}
3493
3494export async function deleteLibraryGroupFromCloud(uid, groupId) {
3495    if (!requirePaidTier()) return;
3496    const { error } = await supabase
3497        .from('library_groups')
3498        .delete()
3499        .eq('id', groupId)
3500        .eq('user_id', uid);
3501    if (error) throw error;
3502    invalidateCache('library_groups_' + uid);
3503}
3504
3505export async function saveLibraryPanelOrder(uid, panelOrder) {
3506    if (!_panelOrderKnown) {
3507        _dispatchSaveError(new Error('Refusing to save the library panel order: this page never read it'), 'saveLibraryPanelOrder');
3508        return;
3509    }
3510    if (!requirePaidTier()) return;
3511    const { error } = await supabase
3512        .from('library_panel_order')
3513        .upsert({
3514            user_id: uid,
3515            panel_order: panelOrder || [],
3516            last_modified: new Date().toISOString(),
3517            last_modified_by: getPageWriterId()
3518        });
3519    if (error) throw error;
3520    _scheduleEntityDocWrite(uid); // 2A: mirror the reorder into the entity doc (order authority)
3521    invalidateCache('library_groups_' + uid);
3522}
3523
3524export function subscribeToLibraryGroups(uid, callback) {
3525    let debounceTimer = null;
3526    let pendingEvents = [];
3527
3528    const channel = supabase
3529        .channel('portal-library-groups')
3530        .on(
3531            'postgres_changes',
3532            {
3533                event: '*',
3534                schema: 'public',
3535                table: 'library_groups',
3536                filter: `user_id=eq.${uid}`
3537            },
3538            (payload) => {
3539                if (payload.new?.last_modified_by === getPageWriterId()) return;
3540                if (payload.eventType === 'DELETE' && payload.old?.last_modified_by === getPageWriterId()) return;
3541
3542                const eventType = payload.eventType;
3543                if (eventType === 'DELETE') {
3544                    pendingEvents.push({ type: 'delete', id: payload.old?.id });
3545                } else {
3546                    pendingEvents.push({ type: eventType.toLowerCase(), id: payload.new?.id });
3547                }
3548
3549                clearTimeout(debounceTimer);
3550                debounceTimer = setTimeout(async () => {
3551                    const events = pendingEvents;
3552                    pendingEvents = [];
3553
3554                    const idsToFetch = [...new Set(
3555                        events.filter(e => e.type !== 'delete' && e.id).map(e => e.id)
3556                    )];
3557                    const fetchedGroups = {};
3558                    if (idsToFetch.length > 0) {
3559                        const { data } = await supabase
3560                            .from('library_groups')
3561                            .select('*')
3562                            .in('id', idsToFetch);
3563                        if (data) {
3564                            for (const row of data) {
3565                                fetchedGroups[row.id] = {
3566                                    id: row.id,
3567                                    type: 'library-group',
3568                                    title: row.title || '',
3569                                    description: row.description || '',
3570                                    libraryIds: row.library_ids || [],
3571                                    notes: row.notes || []
3572                                };
3573                            }
3574                        }
3575                    }
3576
3577                    const resolved = [];
3578                    for (const event of events) {
3579                        if (event.type === 'delete') {
3580                            resolved.push(event);
3581                        } else if (fetchedGroups[event.id]) {
3582                            resolved.push({ type: event.type, group: fetchedGroups[event.id] });
3583                        }
3584                    }
3585                    if (resolved.length > 0) {
3586                        // Invalidate session cache so next load fetches fresh data
3587                        try { sessionStorage.removeItem(CACHE_PREFIX + 'library_groups_' + uid); } catch {}
3588                        callback(resolved);
3589                    }
3590                }, 500);
3591            }
3592        )
3593        .subscribe();
3594
3595    return () => { clearTimeout(debounceTimer); supabase.removeChannel(channel); };
3596}
3597
3598export function subscribeToLibraryPanelOrder(uid, callback) {
3599    const channel = supabase
3600        .channel('portal-library-panel-order')
3601        .on(
3602            'postgres_changes',
3603            {
3604                event: '*',
3605                schema: 'public',
3606                table: 'library_panel_order',
3607                filter: `user_id=eq.${uid}`
3608            },
3609            (payload) => {
3610                if (payload.new?.last_modified_by === getPageWriterId()) return;
3611                const newOrder = payload.new?.panel_order;
3612                if (Array.isArray(newOrder)) {
3613                    _panelOrderKnown = true;
3614                    try { sessionStorage.removeItem(CACHE_PREFIX + 'library_groups_' + uid); } catch {}
3615                    callback(newOrder);
3616                }
3617            }
3618        )
3619        .subscribe();
3620
3621    return () => { supabase.removeChannel(channel); };
3622}
3623
3624/**
3625 * Rebuild libraryPanelOrder to be consistent with current data.
3626 * Mirrors extension's rebuildLibraryPanelOrder().
3627 */
3628export function rebuildLibraryPanelOrder() {
3629    const groupIds = new Set(libraryGroupData.filter(Boolean).map(g => g.id));
3630    const libIds = new Set(libraryData.filter(Boolean).map(f => f.id));
3631    // IDs of libraries inside a group
3632    const groupedLibIds = new Set();
3633    libraryGroupData.forEach(g => {
3634        if (!g) return;
3635        g.libraryIds = (g.libraryIds || []).filter(id => libIds.has(id));
3636        g.libraryIds.forEach(id => groupedLibIds.add(id));
3637    });
3638    // Remove stale entries
3639    libraryPanelOrder = libraryPanelOrder.filter(entry => {
3640        if (!entry) return false;
3641        if (entry.type === 'group') return groupIds.has(entry.id);
3642        if (entry.type === 'library') return libIds.has(entry.id) && !groupedLibIds.has(entry.id);
3643        return false;
3644    });
3645    // Add missing groups
3646    const existingIds = new Set(libraryPanelOrder.map(e => e.id));
3647    for (const g of libraryGroupData) {
3648        if (g && !existingIds.has(g.id)) {
3649            libraryPanelOrder.push({ type: 'group', id: g.id });
3650        }
3651    }
3652    // Add missing ungrouped libraries
3653    for (const f of libraryData) {
3654        if (f && !existingIds.has(f.id) && !groupedLibIds.has(f.id) && !f._linkedRef) {
3655            libraryPanelOrder.push({ type: 'library', id: f.id });
3656        }
3657    }
3658}
3659
3660/**
3661 * Get the set of library IDs that are inside any group.
3662 */
3663export function getGroupedLibraryIds() {
3664    const ids = new Set();
3665    libraryGroupData.forEach(g => {
3666        if (g && g.libraryIds) g.libraryIds.forEach(id => ids.add(id));
3667    });
3668    return ids;
3669}
3670
3671/**
3672 * Find which group (if any) a library belongs to.
3673 */
3674export function getGroupForLibrary(libraryId) {
3675    return libraryGroupData.find(g => g && g.libraryIds && g.libraryIds.includes(libraryId)) || null;
3676}
3677
3678export async function sendLibraryInvite({ to, libraryName, libraryUrl, message, permission }) {
3679    const { data, error } = await supabase.functions.invoke('send-invite', {
3680        body: { to, libraryName, libraryUrl, message, permission },
3681        headers: await authHeaders(),
3682    });
3683    if (error) throw error;
3684    return data;
3685}
3686
3687export async function sendFeedback({ category, message, email, tier }) {
3688    const headers = await authHeaders() || {};
3689    headers['x-feedback-token'] = 'pkfb_t9x2mR7vL4wKqN8bJ3cY6hP1dS5eA0fG';
3690    const { data, error } = await supabase.functions.invoke('send-feedback', {
3691        body: { category, message, email: email || undefined, tier },
3692        headers,
3693    });
3694    if (error) throw error;
3695    return data;
3696}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.