// Pure helpers for the saved-spaces list — framework-free and side-effect-free, // so they're unit-testable in Node (home/test/serverlist.test.mjs) without a DOM. // // The backup blob's `servers` array is the master order; each entry is // { url, name?, starred?, archived? }. Rendering first splits off the **archived** // entries (hidden behind an "Archived" affordance), then partitions the rest into // a **Starred** list and the remainder — each keeping its own relative array order, // so the lists sort independently (reordering one never disturbs another). The // master array is kept active-first-then-archived (setArchived maintains this). // This is the contract the native apps read too: starred spaces populate the rail, // the rest live behind a "more" affordance, and archived ones stay out of view. // spaceRestoreURL marks a home-list navigation as an automatic sign-in. The // instance consumes the marker before creating its ephemeral restore key, then // returns through home's encrypted handoff. Existing query parameters survive. export function spaceRestoreURL(raw) { try { const url = new URL(raw); url.searchParams.set("restore", "1"); return url.toString(); } catch { return raw; } } // partitionServers splits into { starred, others }, each in array order. Callers // pass the ACTIVE subset (archived entries filtered out via partitionArchived). export function partitionServers(servers) { const starred = []; const others = []; for (const s of servers || []) (s.starred ? starred : others).push(s); return { starred, others }; } // partitionArchived splits into { active, archived }, each in array order — the // first cut rendering makes, before active is further split into starred/others. export function partitionArchived(servers) { const active = []; const archived = []; for (const s of servers || []) (s.archived ? archived : active).push(s); return { active, archived }; } // setArchived returns a NEW servers array with the entry's `archived` flag set to // `on` AND repositioned to keep the master order active-first-then-archived: // archive → drop `starred` (an archived space leaves your rail) and send it to // the TOP of the archived group (most-recently-archived first). // unarchive → drop `archived` and send it to the BOTTOM of the active "others" // group (the end of the visible list) — a returning space rejoins // at the back, not ahead of the spaces you kept. // Unknown urls pass through unchanged; the input array/entry is never mutated. export function setArchived(servers, url, on) { const list = servers || []; const target = list.find((s) => s.url === url); if (!target) return list; const rest = list.filter((s) => s.url !== url); const starred = rest.filter((s) => s.starred && !s.archived); const others = rest.filter((s) => !s.starred && !s.archived); const archived = rest.filter((s) => s.archived); if (on) { const base = (({ starred, ...keep }) => keep)(target); // leaving the rail return [...starred, ...others, { ...base, archived: true }, ...archived]; } const base = (({ archived, ...keep }) => keep)(target); // back to the list return [...starred, ...others, base, ...archived]; } // toggleStar returns a NEW servers array with the entry's starred flag flipped // AND repositioned: starring sends it to the END of the Starred list, unstarring // sends it to the TOP of the rest — both of which are the boundary between the // two groups, so the move is a single insert there. (The array is normalised to // starred-first-then-others, which display-partitioning treats identically.) // Unknown urls pass through unchanged. export function toggleStar(servers, url) { const list = servers || []; const target = list.find((s) => s.url === url); if (!target) return list; const updated = target.starred ? (({ starred, ...rest }) => rest)(target) // unstar: drop the flag : { ...target, starred: true }; // star const starred = list.filter((s) => s.url !== url && s.starred); const others = list.filter((s) => s.url !== url && !s.starred); return [...starred, updated, ...others]; } // filterServers returns the subset whose name/host fuzzily matches `query` (a // subsequence match, case-insensitive), preserving array order. Empty query → // everything. Pure so the fuzzy logic is unit-tested without a DOM. export function filterServers(servers, query) { const q = (query || "").trim().toLowerCase(); if (!q) return servers || []; return (servers || []).filter((s) => { let host = ""; try { host = new URL(s.url).host; } catch { host = s.url || ""; } return fuzzyMatch(q, ((s.name || "") + " " + host).toLowerCase()); }); } // fuzzyMatch reports whether every character of `q` appears in `text` in order // (a subsequence — the familiar command-palette style of fuzzy match). `q` is // assumed already lowercased; callers lowercase `text` to match. export function fuzzyMatch(q, text) { if (!q) return true; let i = 0; for (const ch of text) { if (ch === q[i] && ++i === q.length) return true; } return false; } // orderServers returns a NEW servers array reordered to match `orderedUrls` (the // full top-to-bottom display order across both lists — what a drag leaves behind). // Entries whose url isn't listed keep their relative order at the end (defensive); // unknown urls are ignored. Entry objects (with their `starred`) are preserved. export function orderServers(servers, orderedUrls) { const byUrl = new Map((servers || []).map((s) => [s.url, s])); const out = []; for (const url of orderedUrls) { const s = byUrl.get(url); if (s) { out.push(s); byUrl.delete(url); } } for (const s of servers || []) if (byUrl.has(s.url)) out.push(s); return out; } // moveInGroup returns a NEW servers array with the entry at `url` swapped with its // nearest **same-group** (same starred-ness) neighbour in direction `dir` // (-1 = up, +1 = down), skipping over entries from the other group. Returns the // array unchanged if there's no such neighbour (already at an end of its list). // Used by the keyboard reorder fallback (↑/↓ on a focused drag handle). export function moveInGroup(servers, url, dir) { const arr = (servers || []).slice(); const i = arr.findIndex((s) => s.url === url); if (i < 0) return arr; const group = !!arr[i].starred; let j = i + dir; while (j >= 0 && j < arr.length && !!arr[j].starred !== group) j += dir; if (j < 0 || j >= arr.length) return arr; [arr[i], arr[j]] = [arr[j], arr[i]]; return arr; } // ---- removal tombstones --------------------------------------------------- // // Deleting a space on the home page must STICK. Sync never deletes (by design — // see ios HomeSync), so every signed-in device happily re-upserts any server it // still holds; a bare removal from `servers` comes back on the next push. A // removal therefore also writes a tombstone into the blob's top-level `removed` // list — { url, ts } (epoch ms, newest wins per url) — which sync consults: // an entry whose device copy predates its tombstone is dropped and never // re-pushed, while a space the user deliberately re-added AFTER the removal // (device addedAt > ts, or an explicit save/create on the web) clears the // tombstone and wins. Old clients spread-merge unknown top-level keys, so // tombstones survive their writes even though they don't consult them. // HomeVault.swift mirrors these helpers — keep them in lockstep. const TOMBSTONE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; const TOMBSTONE_MAX = 200; // addTombstone returns a NEW removed list with { url, ts } upserted (newest // wins per url) and pruned. The input list is never mutated. export function addTombstone(removed, url, ts) { return pruneTombstones([ ...(removed || []).filter((t) => t && t.url !== url), { url, ts }, ], ts); } // clearTombstone returns a NEW removed list without `url` — the deliberate // re-add path. Returns undefined when nothing is left, so the key drops out of // the blob instead of lingering as []. export function clearTombstone(removed, url) { const list = (removed || []).filter((t) => t && t.url !== url); return list.length ? list : undefined; } // tombstonedAt returns when `url` was removed (epoch ms), or 0 if it wasn't. export function tombstonedAt(removed, url) { const t = (removed || []).find((t) => t && t.url === url); return t && Number.isFinite(t.ts) ? t.ts : 0; } // mergeRemoved unions two removed lists, newest tombstone per url — the merge // rule for version races (two writers, each with its own removals). export function mergeRemoved(a, b) { const byUrl = new Map(); for (const t of [...(a || []), ...(b || [])]) { if (!t || !t.url) continue; const prev = byUrl.get(t.url); if (!prev || (t.ts || 0) > (prev.ts || 0)) byUrl.set(t.url, t); } return [...byUrl.values()]; } // pruneTombstones caps the list so it can't grow unboundedly across years of // use: entries older than a year go (any device that hasn't synced in a year // re-learns the space at worst), and the newest TOMBSTONE_MAX are kept. export function pruneTombstones(removed, now) { return (removed || []) .filter((t) => t && t.url && now - (t.ts || 0) < TOMBSTONE_MAX_AGE_MS) .sort((x, y) => (y.ts || 0) - (x.ts || 0)) .slice(0, TOMBSTONE_MAX); } // soleAdminHold decides whether "Delete your access" must be held, given a // space's /api/me answer. The hold protects against orphaning: if this key is // the space's ONLY admin key, dropping its last saved copy leaves the space // running with no admin, forever. Fail-OPEN by design — only an explicit, // well-formed "yes, admin, and the only one" engages the hold, so an // unreachable space, an older build without the field, or a malformed answer // never makes an entry undeletable. export function soleAdminHold(me) { return !!me && me.role === "admin" && me.sole_admin === true; }