| 1 | // Pure helpers for the saved-spaces list — framework-free and side-effect-free, |
| 2 | // so they're unit-testable in Node (home/test/serverlist.test.mjs) without a DOM. |
| 3 | // |
| 4 | // The backup blob's `servers` array is the master order; each entry is |
| 5 | // { url, name?, starred?, archived? }. Rendering first splits off the **archived** |
| 6 | // entries (hidden behind an "Archived" affordance), then partitions the rest into |
| 7 | // a **Starred** list and the remainder — each keeping its own relative array order, |
| 8 | // so the lists sort independently (reordering one never disturbs another). The |
| 9 | // master array is kept active-first-then-archived (setArchived maintains this). |
| 10 | // This is the contract the native apps read too: starred spaces populate the rail, |
| 11 | // the rest live behind a "more" affordance, and archived ones stay out of view. |
| 12 | |
| 13 | // spaceRestoreURL marks a home-list navigation as an automatic sign-in. The |
| 14 | // instance consumes the marker before creating its ephemeral restore key, then |
| 15 | // returns through home's encrypted handoff. Existing query parameters survive. |
| 16 | export function spaceRestoreURL(raw) { |
| 17 | try { |
| 18 | const url = new URL(raw); |
| 19 | url.searchParams.set("restore", "1"); |
| 20 | return url.toString(); |
| 21 | } catch { |
| 22 | return raw; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // partitionServers splits into { starred, others }, each in array order. Callers |
| 27 | // pass the ACTIVE subset (archived entries filtered out via partitionArchived). |
| 28 | export function partitionServers(servers) { |
| 29 | const starred = []; |
| 30 | const others = []; |
| 31 | for (const s of servers || []) (s.starred ? starred : others).push(s); |
| 32 | return { starred, others }; |
| 33 | } |
| 34 | |
| 35 | // partitionArchived splits into { active, archived }, each in array order — the |
| 36 | // first cut rendering makes, before active is further split into starred/others. |
| 37 | export function partitionArchived(servers) { |
| 38 | const active = []; |
| 39 | const archived = []; |
| 40 | for (const s of servers || []) (s.archived ? archived : active).push(s); |
| 41 | return { active, archived }; |
| 42 | } |
| 43 | |
| 44 | // setArchived returns a NEW servers array with the entry's `archived` flag set to |
| 45 | // `on` AND repositioned to keep the master order active-first-then-archived: |
| 46 | // archive → drop `starred` (an archived space leaves your rail) and send it to |
| 47 | // the TOP of the archived group (most-recently-archived first). |
| 48 | // unarchive → drop `archived` and send it to the BOTTOM of the active "others" |
| 49 | // group (the end of the visible list) — a returning space rejoins |
| 50 | // at the back, not ahead of the spaces you kept. |
| 51 | // Unknown urls pass through unchanged; the input array/entry is never mutated. |
| 52 | export function setArchived(servers, url, on) { |
| 53 | const list = servers || []; |
| 54 | const target = list.find((s) => s.url === url); |
| 55 | if (!target) return list; |
| 56 | const rest = list.filter((s) => s.url !== url); |
| 57 | const starred = rest.filter((s) => s.starred && !s.archived); |
| 58 | const others = rest.filter((s) => !s.starred && !s.archived); |
| 59 | const archived = rest.filter((s) => s.archived); |
| 60 | if (on) { |
| 61 | const base = (({ starred, ...keep }) => keep)(target); // leaving the rail |
| 62 | return [...starred, ...others, { ...base, archived: true }, ...archived]; |
| 63 | } |
| 64 | const base = (({ archived, ...keep }) => keep)(target); // back to the list |
| 65 | return [...starred, ...others, base, ...archived]; |
| 66 | } |
| 67 | |
| 68 | // toggleStar returns a NEW servers array with the entry's starred flag flipped |
| 69 | // AND repositioned: starring sends it to the END of the Starred list, unstarring |
| 70 | // sends it to the TOP of the rest — both of which are the boundary between the |
| 71 | // two groups, so the move is a single insert there. (The array is normalised to |
| 72 | // starred-first-then-others, which display-partitioning treats identically.) |
| 73 | // Unknown urls pass through unchanged. |
| 74 | export function toggleStar(servers, url) { |
| 75 | const list = servers || []; |
| 76 | const target = list.find((s) => s.url === url); |
| 77 | if (!target) return list; |
| 78 | const updated = target.starred |
| 79 | ? (({ starred, ...rest }) => rest)(target) // unstar: drop the flag |
| 80 | : { ...target, starred: true }; // star |
| 81 | const starred = list.filter((s) => s.url !== url && s.starred); |
| 82 | const others = list.filter((s) => s.url !== url && !s.starred); |
| 83 | return [...starred, updated, ...others]; |
| 84 | } |
| 85 | |
| 86 | // filterServers returns the subset whose name/host fuzzily matches `query` (a |
| 87 | // subsequence match, case-insensitive), preserving array order. Empty query → |
| 88 | // everything. Pure so the fuzzy logic is unit-tested without a DOM. |
| 89 | export function filterServers(servers, query) { |
| 90 | const q = (query || "").trim().toLowerCase(); |
| 91 | if (!q) return servers || []; |
| 92 | return (servers || []).filter((s) => { |
| 93 | let host = ""; |
| 94 | try { host = new URL(s.url).host; } catch { host = s.url || ""; } |
| 95 | return fuzzyMatch(q, ((s.name || "") + " " + host).toLowerCase()); |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | // fuzzyMatch reports whether every character of `q` appears in `text` in order |
| 100 | // (a subsequence — the familiar command-palette style of fuzzy match). `q` is |
| 101 | // assumed already lowercased; callers lowercase `text` to match. |
| 102 | export function fuzzyMatch(q, text) { |
| 103 | if (!q) return true; |
| 104 | let i = 0; |
| 105 | for (const ch of text) { |
| 106 | if (ch === q[i] && ++i === q.length) return true; |
| 107 | } |
| 108 | return false; |
| 109 | } |
| 110 | |
| 111 | // orderServers returns a NEW servers array reordered to match `orderedUrls` (the |
| 112 | // full top-to-bottom display order across both lists — what a drag leaves behind). |
| 113 | // Entries whose url isn't listed keep their relative order at the end (defensive); |
| 114 | // unknown urls are ignored. Entry objects (with their `starred`) are preserved. |
| 115 | export function orderServers(servers, orderedUrls) { |
| 116 | const byUrl = new Map((servers || []).map((s) => [s.url, s])); |
| 117 | const out = []; |
| 118 | for (const url of orderedUrls) { |
| 119 | const s = byUrl.get(url); |
| 120 | if (s) { |
| 121 | out.push(s); |
| 122 | byUrl.delete(url); |
| 123 | } |
| 124 | } |
| 125 | for (const s of servers || []) if (byUrl.has(s.url)) out.push(s); |
| 126 | return out; |
| 127 | } |
| 128 | |
| 129 | // moveInGroup returns a NEW servers array with the entry at `url` swapped with its |
| 130 | // nearest **same-group** (same starred-ness) neighbour in direction `dir` |
| 131 | // (-1 = up, +1 = down), skipping over entries from the other group. Returns the |
| 132 | // array unchanged if there's no such neighbour (already at an end of its list). |
| 133 | // Used by the keyboard reorder fallback (↑/↓ on a focused drag handle). |
| 134 | export function moveInGroup(servers, url, dir) { |
| 135 | const arr = (servers || []).slice(); |
| 136 | const i = arr.findIndex((s) => s.url === url); |
| 137 | if (i < 0) return arr; |
| 138 | const group = !!arr[i].starred; |
| 139 | let j = i + dir; |
| 140 | while (j >= 0 && j < arr.length && !!arr[j].starred !== group) j += dir; |
| 141 | if (j < 0 || j >= arr.length) return arr; |
| 142 | [arr[i], arr[j]] = [arr[j], arr[i]]; |
| 143 | return arr; |
| 144 | } |
| 145 | |
| 146 | // ---- removal tombstones --------------------------------------------------- |
| 147 | // |
| 148 | // Deleting a space on the home page must STICK. Sync never deletes (by design — |
| 149 | // see ios HomeSync), so every signed-in device happily re-upserts any server it |
| 150 | // still holds; a bare removal from `servers` comes back on the next push. A |
| 151 | // removal therefore also writes a tombstone into the blob's top-level `removed` |
| 152 | // list — { url, ts } (epoch ms, newest wins per url) — which sync consults: |
| 153 | // an entry whose device copy predates its tombstone is dropped and never |
| 154 | // re-pushed, while a space the user deliberately re-added AFTER the removal |
| 155 | // (device addedAt > ts, or an explicit save/create on the web) clears the |
| 156 | // tombstone and wins. Old clients spread-merge unknown top-level keys, so |
| 157 | // tombstones survive their writes even though they don't consult them. |
| 158 | // HomeVault.swift mirrors these helpers — keep them in lockstep. |
| 159 | |
| 160 | const TOMBSTONE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; |
| 161 | const TOMBSTONE_MAX = 200; |
| 162 | |
| 163 | // addTombstone returns a NEW removed list with { url, ts } upserted (newest |
| 164 | // wins per url) and pruned. The input list is never mutated. |
| 165 | export function addTombstone(removed, url, ts) { |
| 166 | return pruneTombstones([ |
| 167 | ...(removed || []).filter((t) => t && t.url !== url), |
| 168 | { url, ts }, |
| 169 | ], ts); |
| 170 | } |
| 171 | |
| 172 | // clearTombstone returns a NEW removed list without `url` — the deliberate |
| 173 | // re-add path. Returns undefined when nothing is left, so the key drops out of |
| 174 | // the blob instead of lingering as []. |
| 175 | export function clearTombstone(removed, url) { |
| 176 | const list = (removed || []).filter((t) => t && t.url !== url); |
| 177 | return list.length ? list : undefined; |
| 178 | } |
| 179 | |
| 180 | // tombstonedAt returns when `url` was removed (epoch ms), or 0 if it wasn't. |
| 181 | export function tombstonedAt(removed, url) { |
| 182 | const t = (removed || []).find((t) => t && t.url === url); |
| 183 | return t && Number.isFinite(t.ts) ? t.ts : 0; |
| 184 | } |
| 185 | |
| 186 | // mergeRemoved unions two removed lists, newest tombstone per url — the merge |
| 187 | // rule for version races (two writers, each with its own removals). |
| 188 | export function mergeRemoved(a, b) { |
| 189 | const byUrl = new Map(); |
| 190 | for (const t of [...(a || []), ...(b || [])]) { |
| 191 | if (!t || !t.url) continue; |
| 192 | const prev = byUrl.get(t.url); |
| 193 | if (!prev || (t.ts || 0) > (prev.ts || 0)) byUrl.set(t.url, t); |
| 194 | } |
| 195 | return [...byUrl.values()]; |
| 196 | } |
| 197 | |
| 198 | // pruneTombstones caps the list so it can't grow unboundedly across years of |
| 199 | // use: entries older than a year go (any device that hasn't synced in a year |
| 200 | // re-learns the space at worst), and the newest TOMBSTONE_MAX are kept. |
| 201 | export function pruneTombstones(removed, now) { |
| 202 | return (removed || []) |
| 203 | .filter((t) => t && t.url && now - (t.ts || 0) < TOMBSTONE_MAX_AGE_MS) |
| 204 | .sort((x, y) => (y.ts || 0) - (x.ts || 0)) |
| 205 | .slice(0, TOMBSTONE_MAX); |
| 206 | } |
| 207 | |
| 208 | // soleAdminHold decides whether "Delete your access" must be held, given a |
| 209 | // space's /api/me answer. The hold protects against orphaning: if this key is |
| 210 | // the space's ONLY admin key, dropping its last saved copy leaves the space |
| 211 | // running with no admin, forever. Fail-OPEN by design — only an explicit, |
| 212 | // well-formed "yes, admin, and the only one" engages the hold, so an |
| 213 | // unreachable space, an older build without the field, or a malformed answer |
| 214 | // never makes an entry undeletable. |
| 215 | export function soleAdminHold(me) { |
| 216 | return !!me && me.role === "admin" && me.sole_admin === true; |
| 217 | } |
| 218 | |