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.