@tensorix/metahub 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +8 -4
  2. package/README.zh-CN.md +9 -5
  3. package/dist/cli.js +5539 -4193
  4. package/dist/cli.js.map +68 -61
  5. package/dist/core/audit.d.ts.map +1 -1
  6. package/dist/core/blobs-core.d.ts.map +1 -1
  7. package/dist/core/compact.d.ts.map +1 -1
  8. package/dist/core/config.d.ts.map +1 -1
  9. package/dist/core/crdt.d.ts +12 -4
  10. package/dist/core/crdt.d.ts.map +1 -1
  11. package/dist/core/databases.d.ts +3 -2
  12. package/dist/core/databases.d.ts.map +1 -1
  13. package/dist/core/db.d.ts +1 -1
  14. package/dist/core/db.d.ts.map +1 -1
  15. package/dist/core/documents.d.ts.map +1 -1
  16. package/dist/core/driver.d.ts +5 -1
  17. package/dist/core/driver.d.ts.map +1 -1
  18. package/dist/core/errors.d.ts +19 -1
  19. package/dist/core/errors.d.ts.map +1 -1
  20. package/dist/core/guest-intent.d.ts +1 -1
  21. package/dist/core/guest-intent.d.ts.map +1 -1
  22. package/dist/core/history-cache.d.ts +13 -0
  23. package/dist/core/history-cache.d.ts.map +1 -0
  24. package/dist/core/history.d.ts.map +1 -1
  25. package/dist/core/hlc.d.ts +13 -0
  26. package/dist/core/hlc.d.ts.map +1 -1
  27. package/dist/core/integrity.d.ts +19 -2
  28. package/dist/core/integrity.d.ts.map +1 -1
  29. package/dist/core/node.d.ts +52 -6
  30. package/dist/core/node.d.ts.map +1 -1
  31. package/dist/core/resolve.d.ts +8 -0
  32. package/dist/core/resolve.d.ts.map +1 -1
  33. package/dist/core/schema-init.d.ts +4 -1
  34. package/dist/core/schema-init.d.ts.map +1 -1
  35. package/dist/core/schema.d.ts +1 -1
  36. package/dist/core/schema.d.ts.map +1 -1
  37. package/dist/core/search.d.ts +1 -1
  38. package/dist/core/search.d.ts.map +1 -1
  39. package/dist/core/site-channel-store.d.ts +1 -1
  40. package/dist/core/site-channel-store.d.ts.map +1 -1
  41. package/dist/core/snapshot.d.ts.map +1 -1
  42. package/dist/core/sync/auth.d.ts +2 -4
  43. package/dist/core/sync/auth.d.ts.map +1 -1
  44. package/dist/core/sync/cf-oauth.d.ts.map +1 -1
  45. package/dist/core/sync/client.d.ts +4 -0
  46. package/dist/core/sync/client.d.ts.map +1 -1
  47. package/dist/core/sync/devices.d.ts +6 -0
  48. package/dist/core/sync/devices.d.ts.map +1 -1
  49. package/dist/core/sync/drop-protocol.d.ts.map +1 -1
  50. package/dist/core/sync/drop-pull.d.ts +1 -1
  51. package/dist/core/sync/drop-pull.d.ts.map +1 -1
  52. package/dist/core/sync/http-status.d.ts.map +1 -1
  53. package/dist/core/sync/locale.d.ts +8 -0
  54. package/dist/core/sync/locale.d.ts.map +1 -0
  55. package/dist/core/sync/page-messages.d.ts +106 -0
  56. package/dist/core/sync/page-messages.d.ts.map +1 -0
  57. package/dist/core/sync/pairing.d.ts +4 -1
  58. package/dist/core/sync/pairing.d.ts.map +1 -1
  59. package/dist/core/sync/peers-routes.d.ts.map +1 -1
  60. package/dist/core/sync/peers.d.ts +3 -1
  61. package/dist/core/sync/peers.d.ts.map +1 -1
  62. package/dist/core/sync/protocol.d.ts +1 -0
  63. package/dist/core/sync/protocol.d.ts.map +1 -1
  64. package/dist/core/sync/remote-shares-cache.d.ts +32 -0
  65. package/dist/core/sync/remote-shares-cache.d.ts.map +1 -0
  66. package/dist/core/sync/room-client.d.ts +1 -1
  67. package/dist/core/sync/room-client.d.ts.map +1 -1
  68. package/dist/core/sync/room-protocol.d.ts +1 -1
  69. package/dist/core/sync/room-protocol.d.ts.map +1 -1
  70. package/dist/core/sync/room-serve.d.ts.map +1 -1
  71. package/dist/core/sync/routes.d.ts +1 -1
  72. package/dist/core/sync/routes.d.ts.map +1 -1
  73. package/dist/core/sync/server.d.ts +2 -0
  74. package/dist/core/sync/server.d.ts.map +1 -1
  75. package/dist/core/sync/share-actions.d.ts +7 -2
  76. package/dist/core/sync/share-actions.d.ts.map +1 -1
  77. package/dist/core/sync/share-copy.d.ts +7 -0
  78. package/dist/core/sync/share-copy.d.ts.map +1 -0
  79. package/dist/core/sync/share-export.d.ts +3 -1
  80. package/dist/core/sync/share-export.d.ts.map +1 -1
  81. package/dist/core/sync/share-serve.d.ts.map +1 -1
  82. package/dist/core/sync/sites-serve.d.ts.map +1 -1
  83. package/dist/core/sync/storage.d.ts +5 -1
  84. package/dist/core/sync/storage.d.ts.map +1 -1
  85. package/dist/core/tables.d.ts +16 -0
  86. package/dist/core/tables.d.ts.map +1 -0
  87. package/dist/db-worker.js +131 -88
  88. package/dist/edge-worker.js +353 -90
  89. package/dist/index.js +2051 -471
  90. package/dist/index.js.map +57 -49
  91. package/dist/metahub-sdk.js +5 -5
  92. package/dist/mh-runtime.js +9 -1
  93. package/dist/sw.js +15 -7
  94. package/dist/webui/server/assets.d.ts.map +1 -1
  95. package/dist/webui/server/changes-route.d.ts +2 -0
  96. package/dist/webui/server/changes-route.d.ts.map +1 -1
  97. package/dist/webui/server/edge-routes.d.ts.map +1 -1
  98. package/dist/webui/server/routes.d.ts +2 -0
  99. package/dist/webui/server/routes.d.ts.map +1 -1
  100. package/dist/webui-fmt.js +1 -1
  101. package/dist/webui.js +173 -214
  102. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -12,19 +12,43 @@ var __esm = (fn, res, err) => () => {
12
12
  };
13
13
 
14
14
  // src/core/errors.ts
15
+ function interpolateMsg(s, params) {
16
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
17
+ }
18
+ function mhError(code, key, params) {
19
+ return new MhError(code, interpolateMsg(key, params), { key, params });
20
+ }
21
+ function isSqliteBusy(e) {
22
+ if (!(e instanceof Error))
23
+ return false;
24
+ const code = e.code;
25
+ if (typeof code === "string" && SQLITE_BUSY_RE.test(code))
26
+ return true;
27
+ return e.message === "database is locked";
28
+ }
29
+ function asMhError(e) {
30
+ if (e instanceof MhError)
31
+ return e;
32
+ if (isSqliteBusy(e))
33
+ return mhError("busy", "\u6570\u636E\u5E93\u6B63\u88AB\u53E6\u4E00\u4E2A\u8FDB\u7A0B\u5199\u5165\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
34
+ return null;
35
+ }
15
36
  function errorCode(e) {
16
- return e instanceof MhError ? e.code : undefined;
37
+ return asMhError(e)?.code;
17
38
  }
18
- var MhError;
39
+ var MhError, SQLITE_BUSY_RE;
19
40
  var init_errors = __esm(() => {
20
41
  MhError = class MhError extends Error {
21
42
  code;
22
- constructor(code, message) {
43
+ i18n;
44
+ constructor(code, message, i18n) {
23
45
  super(message);
24
46
  this.code = code;
47
+ this.i18n = i18n;
25
48
  this.name = "MhError";
26
49
  }
27
50
  };
51
+ SQLITE_BUSY_RE = /^SQLITE_(BUSY|LOCKED)/;
28
52
  });
29
53
 
30
54
  // src/core/paths.ts
@@ -41,6 +65,12 @@ function cacheDir() {
41
65
  }
42
66
  var init_paths = () => {};
43
67
 
68
+ // src/core/driver.ts
69
+ function writeTx(db, fn) {
70
+ const tx = db.transaction(fn);
71
+ return (tx.immediate ?? tx)();
72
+ }
73
+
44
74
  // src/core/schema.ts
45
75
  var CORE_SCHEMA = `
46
76
  CREATE TABLE IF NOT EXISTS meta (
@@ -352,6 +382,22 @@ CREATE TABLE IF NOT EXISTS blob_policy (
352
382
  redundancy TEXT, -- 'all' | 'any'
353
383
  __deleted INTEGER NOT NULL DEFAULT 0
354
384
  );
385
+
386
+ -- Synced device roster (system tier, see tables.ts): one row per node id, written
387
+ -- by the device itself on open (describeSelf) and by anyone who renames it.
388
+ -- Replicates so every device shows the same name and icon for a peer \u2014 the only
389
+ -- record a purely bucket-joined device leaves besides its oplog stream. Each
390
+ -- column is its own LWW register, so a rename and a platform refresh never
391
+ -- clobber each other. meta.node_label survives as a node-local read fallback.
392
+ CREATE TABLE IF NOT EXISTS nodes (
393
+ id TEXT PRIMARY KEY, -- node_id
394
+ label TEXT, -- shared display name; any device may set it
395
+ platform TEXT, -- macos | windows | linux | ios | android | web
396
+ form TEXT, -- laptop | desktop | phone | server | browser
397
+ app TEXT, -- cli | server | desktop | web
398
+ first_seen INTEGER, -- epoch ms of the device's first self-description
399
+ __deleted INTEGER NOT NULL DEFAULT 0
400
+ );
355
401
  `, FTS_SCHEMA = `
356
402
  CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
357
403
  kind UNINDEXED,
@@ -410,30 +456,142 @@ function getNodeId(db) {
410
456
  db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?)").run(id);
411
457
  return id;
412
458
  }
413
- function getNodeLabel(db) {
459
+ function readNodeMeta(db, nodeId) {
460
+ const r = db.query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ? AND __deleted = 0`).get(nodeId);
461
+ return r ? rowToMeta(r) : null;
462
+ }
463
+ function listNodeMeta(db) {
464
+ return db.query(`SELECT ${NODE_COLS} FROM nodes WHERE __deleted = 0`).all().map(rowToMeta);
465
+ }
466
+ function legacySelfLabel(db) {
414
467
  const row = db.query("SELECT value FROM meta WHERE key = 'node_label'").get();
415
468
  return row?.value ?? null;
416
469
  }
417
- function setNodeLabel(db, label) {
418
- if (label == null || label.trim() === "") {
470
+ function mirrorLegacySelfLabel(db, label) {
471
+ if (label == null) {
419
472
  db.query("DELETE FROM meta WHERE key = 'node_label'").run();
420
473
  return;
421
474
  }
422
- db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label.trim());
475
+ db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label);
476
+ }
477
+ function nodeLabelOf(db, nodeId) {
478
+ const synced = readNodeMeta(db, nodeId)?.label;
479
+ if (synced)
480
+ return synced;
481
+ const peer = db.query("SELECT label FROM peers WHERE node_id = ? AND label IS NOT NULL AND label <> '' LIMIT 1").get(nodeId);
482
+ if (peer?.label)
483
+ return peer.label;
484
+ return nodeId === getNodeId(db) ? legacySelfLabel(db) : null;
485
+ }
486
+ function getNodeLabel(db) {
487
+ return nodeLabelOf(db, getNodeId(db));
488
+ }
489
+ function setNodeLabel(db, label, nodeId) {
490
+ const id = nodeId ?? getNodeId(db);
491
+ const next = label == null || label.trim() === "" ? null : label.trim();
492
+ const cur = readNodeMeta(db, id);
493
+ if (id === getNodeId(db))
494
+ mirrorLegacySelfLabel(db, next);
495
+ if (cur && cur.label === next)
496
+ return;
497
+ if (!cur && next == null)
498
+ return;
499
+ withChangeGroup(db, "node", () => {
500
+ emit(db, "nodes", id, "label", next);
501
+ });
502
+ }
503
+ function describeSelf(db, info = {}) {
504
+ const id = getNodeId(db);
505
+ const cur = readNodeMeta(db, id);
506
+ const fields = {};
507
+ if (info.platform !== undefined && (cur?.platform ?? null) !== info.platform)
508
+ fields.platform = info.platform;
509
+ if (info.form !== undefined && (cur?.form ?? null) !== info.form)
510
+ fields.form = info.form;
511
+ if (info.app !== undefined && (cur?.app ?? null) !== info.app)
512
+ fields.app = info.app;
513
+ if (cur?.first_seen == null)
514
+ fields.first_seen = info.now ?? Date.now();
515
+ if (!cur?.label) {
516
+ const seed = legacySelfLabel(db) ?? info.defaultLabel ?? null;
517
+ if (seed)
518
+ fields.label = seed;
519
+ }
520
+ if (Object.keys(fields).length > 0) {
521
+ withChangeGroup(db, "node", () => {
522
+ for (const [col, v] of Object.entries(fields))
523
+ emit(db, "nodes", id, col, v);
524
+ });
525
+ }
526
+ return readNodeMeta(db, id);
527
+ }
528
+ function platformFromProcess(p) {
529
+ switch (p) {
530
+ case "darwin":
531
+ return "macos";
532
+ case "win32":
533
+ return "windows";
534
+ case "linux":
535
+ return "linux";
536
+ case "android":
537
+ return "android";
538
+ default:
539
+ return null;
540
+ }
541
+ }
542
+ function defaultForm(platform, app) {
543
+ if (app === "web")
544
+ return "browser";
545
+ if (platform === "ios" || platform === "android")
546
+ return "phone";
547
+ if (platform === "linux")
548
+ return "server";
549
+ if (platform === "macos" || platform === "windows")
550
+ return "laptop";
551
+ return null;
423
552
  }
424
553
  function displayNodes(db) {
425
554
  const self = getNodeId(db);
426
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
427
- return [
428
- { node_id: self, label: getNodeLabel(db), self: true },
429
- ...peers.filter((p) => p.node_id !== self).map((p) => ({ ...p, self: false }))
430
- ];
555
+ const ids = new Set([self]);
556
+ for (const r of listNodeMeta(db))
557
+ ids.add(r.node_id);
558
+ const peers = db.query("SELECT node_id FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
559
+ for (const p of peers)
560
+ ids.add(p.node_id);
561
+ const out = [...ids].map((node_id) => {
562
+ const m = readNodeMeta(db, node_id);
563
+ return {
564
+ node_id,
565
+ label: nodeLabelOf(db, node_id),
566
+ self: node_id === self,
567
+ platform: m?.platform ?? null,
568
+ form: m?.form ?? null,
569
+ app: m?.app ?? null
570
+ };
571
+ });
572
+ out.sort((a, b) => Number(b.self) - Number(a.self));
573
+ return out;
431
574
  }
575
+ var NODE_COLS = "id, label, platform, form, app, first_seen", rowToMeta = (r) => ({
576
+ node_id: r.id,
577
+ label: r.label,
578
+ platform: r.platform ?? null,
579
+ form: r.form ?? null,
580
+ app: r.app ?? null,
581
+ first_seen: r.first_seen
582
+ });
432
583
  var init_node = __esm(() => {
433
584
  init_ids();
585
+ init_crdt();
434
586
  });
435
587
 
436
588
  // src/core/hlc.ts
589
+ function hlcSkewBound(now) {
590
+ return formatHlc({ millis: now + HLC_MAX_SKEW_MS, counter: MAX_HLC_COUNTER, node: "~~~~~~~~" });
591
+ }
592
+ function isSkewedHlc(hlc, now) {
593
+ return hlc > hlcSkewBound(now);
594
+ }
437
595
  function carryCounter(millis, counter) {
438
596
  if (counter <= MAX_HLC_COUNTER)
439
597
  return { millis, counter };
@@ -464,6 +622,8 @@ function writeLast(db, h) {
464
622
  }
465
623
  function nextHlc(db, node, now = Date.now()) {
466
624
  const last = readLast(db, node);
625
+ if (last.millis > now + HLC_MAX_SKEW_MS)
626
+ throw mhError("clock_skew", "\u672C\u673A\u903B\u8F91\u65F6\u949F\u6BD4\u7CFB\u7EDF\u65F6\u95F4\u8D85\u524D {min} \u5206\u949F\uFF08\u7CFB\u7EDF\u65F6\u949F\u66FE\u88AB\u8C03\u9519\uFF0C\u6216\u6536\u5230\u8FC7\u65F6\u949F\u9519\u8BEF\u8BBE\u5907\u7684\u6570\u636E\uFF09\uFF0C\u8FD0\u884C mh repair --clock \u4FEE\u590D", { min: Math.ceil((last.millis - now) / 60000) });
467
627
  const millis = Math.max(last.millis, now);
468
628
  const counter = millis === last.millis ? last.counter + 1 : 0;
469
629
  const h = { ...carryCounter(millis, counter), node };
@@ -475,6 +635,8 @@ function observeHlc(db, node, remote, now = Date.now()) {
475
635
  const r = parseHlc(remote);
476
636
  if (!Number.isFinite(r.millis) || !Number.isFinite(r.counter))
477
637
  return;
638
+ if (r.millis > now + HLC_MAX_SKEW_MS)
639
+ return;
478
640
  const millis = Math.max(last.millis, r.millis, now);
479
641
  let counter;
480
642
  if (millis === last.millis && millis === r.millis)
@@ -487,7 +649,18 @@ function observeHlc(db, node, remote, now = Date.now()) {
487
649
  counter = 0;
488
650
  writeLast(db, { ...carryCounter(millis, counter), node });
489
651
  }
490
- var MAX_HLC_COUNTER = 65535;
652
+ function persistedHlc(db) {
653
+ const row = db.query("SELECT value FROM meta WHERE key = 'hlc'").get();
654
+ return row ? parseHlc(row.value) : null;
655
+ }
656
+ function resetHlc(db, node, now = Date.now()) {
657
+ writeLast(db, { millis: now, counter: 0, node });
658
+ }
659
+ var MAX_HLC_COUNTER = 65535, HLC_MAX_SKEW_MS;
660
+ var init_hlc = __esm(() => {
661
+ init_errors();
662
+ HLC_MAX_SKEW_MS = 5 * 60000;
663
+ });
491
664
 
492
665
  // src/core/blocks.ts
493
666
  function parseDocBlocks(md) {
@@ -590,6 +763,78 @@ function lcs(a, b) {
590
763
  return pairs;
591
764
  }
592
765
 
766
+ // src/core/history-cache.ts
767
+ function cacheFor(db) {
768
+ let c = caches.get(db);
769
+ if (!c) {
770
+ c = { docs: new Map, blockToDoc: new Map };
771
+ caches.set(db, c);
772
+ }
773
+ return c;
774
+ }
775
+ function dropDoc(c, docId) {
776
+ const e = c.docs.get(docId);
777
+ if (!e)
778
+ return;
779
+ c.docs.delete(docId);
780
+ for (const b of e.blockIds)
781
+ if (c.blockToDoc.get(b) === docId)
782
+ c.blockToDoc.delete(b);
783
+ }
784
+ function getDocHistory(db, docId) {
785
+ const c = caches.get(db);
786
+ const e = c?.docs.get(docId);
787
+ if (!c || !e)
788
+ return;
789
+ c.docs.delete(docId);
790
+ c.docs.set(docId, e);
791
+ return e;
792
+ }
793
+ function setDocHistory(db, docId, changes, blockIds) {
794
+ const c = cacheFor(db);
795
+ dropDoc(c, docId);
796
+ const entry = { changes, blockIds: new Set(blockIds), revisions: null, states: new Map };
797
+ c.docs.set(docId, entry);
798
+ for (const b of entry.blockIds)
799
+ c.blockToDoc.set(b, docId);
800
+ while (c.docs.size > MAX_DOCS)
801
+ dropDoc(c, c.docs.keys().next().value);
802
+ return entry;
803
+ }
804
+ function rememberDocState(entry, at, state) {
805
+ if (entry.states.size >= MAX_STATES)
806
+ entry.states.delete(entry.states.keys().next().value);
807
+ entry.states.set(at, state);
808
+ }
809
+ function noteChange(db, dataset, rowId, col, value) {
810
+ const c = caches.get(db);
811
+ if (!c)
812
+ return;
813
+ if (dataset === "documents") {
814
+ dropDoc(c, rowId);
815
+ return;
816
+ }
817
+ if (dataset !== "doc_blocks")
818
+ return;
819
+ const owner = c.blockToDoc.get(rowId);
820
+ if (owner)
821
+ dropDoc(c, owner);
822
+ if (col === "doc_id" && value) {
823
+ try {
824
+ const target = JSON.parse(value);
825
+ if (typeof target === "string")
826
+ dropDoc(c, target);
827
+ } catch {}
828
+ }
829
+ }
830
+ function invalidateHistory(db) {
831
+ caches.delete(db);
832
+ }
833
+ var caches, MAX_DOCS = 16, MAX_STATES = 64;
834
+ var init_history_cache = __esm(() => {
835
+ caches = new WeakMap;
836
+ });
837
+
593
838
  // src/core/intent-retention.ts
594
839
  function safeNow(now) {
595
840
  return Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
@@ -638,27 +883,27 @@ function withNodeId(node, fn) {
638
883
  currentNodeOverride = prev;
639
884
  }
640
885
  }
641
- function withChangeGroup(label, fn) {
886
+ function withChangeGroup(db, label, fn) {
642
887
  if (currentTxn !== null)
643
888
  return fn();
644
889
  currentTxn = (currentActor ? currentActor + "/" : "") + (label ? label + ":" : "") + randomSuffix(8);
645
890
  try {
646
- return fn();
891
+ return writeTx(db, fn);
647
892
  } finally {
648
893
  currentTxn = null;
649
894
  }
650
895
  }
651
- function withTxnId(txn, fn) {
896
+ function withTxnId(db, txn, fn) {
652
897
  const prev = currentTxn;
653
898
  currentTxn = txn;
654
899
  try {
655
- return fn();
900
+ return writeTx(db, fn);
656
901
  } finally {
657
902
  currentTxn = prev;
658
903
  }
659
904
  }
660
905
  function grouped(fn, label = null) {
661
- return (...args) => withChangeGroup(label, () => fn(...args));
906
+ return (db, ...args) => withChangeGroup(db, label, () => fn(db, ...args));
662
907
  }
663
908
  function decodeMaterializeValue(dataset, col, valueJson) {
664
909
  if (valueJson !== null)
@@ -705,6 +950,16 @@ function materialize(db, dataset, rowId, col, valueJson) {
705
950
  }
706
951
  return;
707
952
  }
953
+ if (dataset === "databases" && col.startsWith("meta.")) {
954
+ ensureRow(db, "databases", rowId);
955
+ const key = col.slice(5);
956
+ if (valueJson === null) {
957
+ db.query(`UPDATE databases SET meta = json_remove(coalesce(meta, '{}'), '$."' || ? || '"') WHERE id = ?`).run(key, rowId);
958
+ } else {
959
+ db.query(`UPDATE databases SET meta = json_set(coalesce(meta, '{}'), '$."' || ? || '"', json(?)) WHERE id = ?`).run(key, valueJson, rowId);
960
+ }
961
+ return;
962
+ }
708
963
  const d = DOMAIN[dataset];
709
964
  if (!d || !d.cols.has(col))
710
965
  return;
@@ -722,6 +977,8 @@ function materialize(db, dataset, rowId, col, valueJson) {
722
977
  function applyChange(db, c) {
723
978
  const inserted = db.query("INSERT OR IGNORE INTO crdt_changes (hlc, node_id, dataset, row_id, col, value, txn) VALUES (?, ?, ?, ?, ?, ?, ?)").run(c.hlc, c.node_id, c.dataset, c.row_id, c.col, c.value, c.txn ?? null).changes > 0;
724
979
  const cur = db.query("SELECT MAX(hlc) AS h FROM crdt_changes WHERE dataset = ? AND row_id = ? AND col = ?").get(c.dataset, c.row_id, c.col);
980
+ if (inserted)
981
+ noteChange(db, c.dataset, c.row_id, c.col, c.value);
725
982
  if (cur.h !== c.hlc)
726
983
  return { inserted, winner: false };
727
984
  try {
@@ -735,18 +992,21 @@ function applyChange(db, c) {
735
992
  return { inserted, winner: true };
736
993
  }
737
994
  function emit(db, dataset, rowId, col, value) {
738
- const node = currentNodeOverride ?? getNodeId(db);
739
- const change = {
740
- hlc: nextHlc(db, node),
741
- node_id: node,
742
- dataset,
743
- row_id: rowId,
744
- col,
745
- value: value === undefined ? null : JSON.stringify(value),
746
- txn: currentTxn
995
+ const mint = () => {
996
+ const node = currentNodeOverride ?? getNodeId(db);
997
+ const change = {
998
+ hlc: nextHlc(db, node),
999
+ node_id: node,
1000
+ dataset,
1001
+ row_id: rowId,
1002
+ col,
1003
+ value: value === undefined ? null : JSON.stringify(value),
1004
+ txn: currentTxn
1005
+ };
1006
+ applyChange(db, change);
1007
+ return change;
747
1008
  };
748
- applyChange(db, change);
749
- return change;
1009
+ return currentTxn !== null ? mint() : writeTx(db, mint);
750
1010
  }
751
1011
  function emitFields(db, dataset, rowId, fields) {
752
1012
  const out = [];
@@ -756,16 +1016,24 @@ function emitFields(db, dataset, rowId, fields) {
756
1016
  return out;
757
1017
  }
758
1018
  function ingest(db, changes, opts = {}) {
1019
+ return ingestDetailed(db, changes, opts).received;
1020
+ }
1021
+ function ingestDetailed(db, changes, opts = {}) {
759
1022
  const now = opts.now ?? Date.now();
760
1023
  pruneExpiredIntentReceipts(db, now);
761
1024
  const node = getNodeId(db);
762
1025
  let received = 0;
763
1026
  let poisoned = 0;
764
- const tx = db.transaction((cs) => {
765
- for (const c of cs) {
1027
+ const skewed = {};
1028
+ const tx = () => {
1029
+ for (const c of changes) {
766
1030
  if (isExpiredIntentReceipt(c, now))
767
1031
  continue;
768
- observeHlc(db, node, c.hlc);
1032
+ if (isSkewedHlc(c.hlc, now)) {
1033
+ skewed[c.node_id] = (skewed[c.node_id] ?? 0) + 1;
1034
+ continue;
1035
+ }
1036
+ observeHlc(db, node, c.hlc, now);
769
1037
  try {
770
1038
  if (applyChange(db, c).inserted)
771
1039
  received++;
@@ -774,11 +1042,13 @@ function ingest(db, changes, opts = {}) {
774
1042
  console.error(`[ingest] skipped a change that failed to apply (dataset=${c.dataset} col=${c.col} row=${c.row_id}): ${e.message}`);
775
1043
  }
776
1044
  }
777
- });
778
- tx(changes);
1045
+ };
1046
+ writeTx(db, tx);
779
1047
  if (poisoned)
780
1048
  console.error(`[ingest] ${poisoned} change(s) skipped this batch (see above)`);
781
- return received;
1049
+ for (const [n, k] of Object.entries(skewed))
1050
+ console.error(`[ingest] refused ${k} change(s) from node ${n}: stamped more than 5 min in the future (that device should run mh repair --clock)`);
1051
+ return { received, skewed };
782
1052
  }
783
1053
  function changesSince(db, since, opts = {}) {
784
1054
  const rows = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes WHERE hlc > ? ORDER BY hlc`).all(since);
@@ -814,7 +1084,9 @@ function changesAfterSeq(db, seq, opts = {}) {
814
1084
  var CHANGE_COLS, CHANGE_SELECT, currentTxn = null, currentActor = null, ACTOR_TAG_RE, currentNodeOverride = null, DOMAIN, RECORD_META, OPLOG_ONLY_DATASETS, NOT_NULL_ZERO_COLS;
815
1085
  var init_crdt = __esm(() => {
816
1086
  init_node();
1087
+ init_hlc();
817
1088
  init_ids();
1089
+ init_history_cache();
818
1090
  init_intent_retention();
819
1091
  CHANGE_COLS = ["hlc", "node_id", "dataset", "row_id", "col", "value", "txn"];
820
1092
  CHANGE_SELECT = CHANGE_COLS.join(", ");
@@ -870,6 +1142,10 @@ var init_crdt = __esm(() => {
870
1142
  blob_policy: {
871
1143
  table: "blob_policy",
872
1144
  cols: new Set(["full_nodes", "redundancy", "__deleted"])
1145
+ },
1146
+ nodes: {
1147
+ table: "nodes",
1148
+ cols: new Set(["label", "platform", "form", "app", "first_seen", "__deleted"])
873
1149
  }
874
1150
  };
875
1151
  RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
@@ -887,7 +1163,8 @@ var init_crdt = __esm(() => {
887
1163
  "sites:spa",
888
1164
  "site_channels:__deleted",
889
1165
  "site_files:__deleted",
890
- "blob_policy:__deleted"
1166
+ "blob_policy:__deleted",
1167
+ "nodes:__deleted"
891
1168
  ]);
892
1169
  });
893
1170
 
@@ -1116,7 +1393,19 @@ var init_properties = __esm(() => {
1116
1393
  function rowOut(r) {
1117
1394
  if (!r)
1118
1395
  return null;
1119
- return { ...r, meta: r.meta === null ? null : JSON.parse(r.meta) };
1396
+ const meta = r.meta === null ? null : JSON.parse(r.meta);
1397
+ return { ...r, meta: meta && Object.keys(meta).length ? meta : null };
1398
+ }
1399
+ function emitMetaPatch(db, id, cur, patch) {
1400
+ for (const [k, v] of Object.entries(patch)) {
1401
+ if (k.includes("."))
1402
+ throw new MhError("invalid_input", `meta key must not contain ".": ${k}`);
1403
+ const next = v === undefined ? null : v;
1404
+ const prev = cur && k in cur ? cur[k] : null;
1405
+ if (JSON.stringify(prev ?? null) === JSON.stringify(next ?? null))
1406
+ continue;
1407
+ emit(db, "databases", id, `meta.${k}`, next === null ? undefined : next);
1408
+ }
1120
1409
  }
1121
1410
  function getDatabase(db, id) {
1122
1411
  return rowOut(db.query(`SELECT ${DATABASE_SELECT} FROM databases WHERE id = ? AND __deleted = 0`).get(id));
@@ -1155,7 +1444,9 @@ var init_databases = __esm(() => {
1155
1444
  if (fields.meta !== undefined) {
1156
1445
  if (fields.meta !== null && (typeof fields.meta !== "object" || Array.isArray(fields.meta)))
1157
1446
  throw new MhError("invalid_input", "meta must be a JSON object or null");
1158
- emit(db, "databases", id, "meta", fields.meta);
1447
+ const cur = getDatabase(db, id).meta;
1448
+ const patch = fields.meta ?? Object.fromEntries(Object.keys(cur ?? {}).map((k) => [k, null]));
1449
+ emitMetaPatch(db, id, cur, patch);
1159
1450
  }
1160
1451
  return getDatabase(db, id);
1161
1452
  });
@@ -1169,8 +1460,7 @@ var init_databases = __esm(() => {
1169
1460
  });
1170
1461
  if (src.meta !== null) {
1171
1462
  const { collapsed: _hidden, ...meta } = src.meta;
1172
- if (Object.keys(meta).length)
1173
- emit(db, "databases", dup.id, "meta", meta);
1463
+ emitMetaPatch(db, dup.id, null, meta);
1174
1464
  }
1175
1465
  const propIdMap = new Map;
1176
1466
  const selfRelNew = new Set;
@@ -1298,7 +1588,37 @@ ${fmt(shown)}
1298
1588
  function resolveRef(db, ref, opts = {}) {
1299
1589
  return resolveEntity(db, ref, opts).id;
1300
1590
  }
1301
- var SPECS, PUBLIC_KINDS, HI = "{";
1591
+ function lookupCandidates(db, ref, opts = {}) {
1592
+ const trimmed = ref.trim();
1593
+ if (!trimmed)
1594
+ return [];
1595
+ const limit = Math.max(1, Math.min(opts.limit ?? LOOKUP_LIMIT, 100));
1596
+ const cands = resolveCandidates(db, trimmed).filter((c) => c.kind !== "prop");
1597
+ cands.sort((a, b) => Number(b.id === trimmed) - Number(a.id === trimmed));
1598
+ const titleCache = new Map;
1599
+ const out = [];
1600
+ for (const c of cands.slice(0, limit)) {
1601
+ if (c.kind === "db") {
1602
+ out.push({ ...c, database_id: null });
1603
+ continue;
1604
+ }
1605
+ const row = db.query(`SELECT database_id FROM ${SPECS[c.kind].table} WHERE id = ?`).get(c.id);
1606
+ const databaseId = row?.database_id ?? null;
1607
+ let label = c.label;
1608
+ if (c.kind === "rec" && !label && databaseId) {
1609
+ if (!titleCache.has(databaseId))
1610
+ titleCache.set(databaseId, titlePropId(db, databaseId));
1611
+ const tp = titleCache.get(databaseId);
1612
+ if (tp) {
1613
+ const t = db.query(`SELECT data ->> '${tp.replace(/'/g, "''")}' AS title FROM records WHERE id = ?`).get(c.id);
1614
+ label = t?.title ? String(t.title) : "";
1615
+ }
1616
+ }
1617
+ out.push({ kind: c.kind, id: c.id, label, database_id: databaseId });
1618
+ }
1619
+ return out;
1620
+ }
1621
+ var SPECS, PUBLIC_KINDS, HI = "{", LOOKUP_LIMIT = 20;
1302
1622
  var init_resolve = __esm(() => {
1303
1623
  init_ids();
1304
1624
  init_errors();
@@ -1999,25 +2319,6 @@ var init_documents = __esm(() => {
1999
2319
  throw new MhError("invalid_input", `anchor not found: no match for --old`);
2000
2320
  if (count > 1 && !opts.replaceAll)
2001
2321
  throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
2002
- if (!opts.old.includes(`
2003
-
2004
- `)) {
2005
- const affected = blocks.filter((b) => b.text.includes(opts.old));
2006
- const next = affected.map((b) => opts.replaceAll ? b.text.replaceAll(opts.old, () => opts.new) : b.text.replace(opts.old, () => opts.new));
2007
- if (next.every((t) => t.length > 0 && !t.includes(`
2008
-
2009
- `))) {
2010
- const targets = opts.replaceAll ? affected : affected.slice(0, 1);
2011
- let wrote = false;
2012
- targets.forEach((b, i) => {
2013
- if (next[i] !== b.text) {
2014
- emit(db, "doc_blocks", b.id, "text", next[i]);
2015
- wrote = true;
2016
- }
2017
- });
2018
- return { id, changed: wrote, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
2019
- }
2020
- }
2021
2322
  const newBody = opts.replaceAll ? body.replaceAll(opts.old, () => opts.new) : body.replace(opts.old, () => opts.new);
2022
2323
  reconcileBody(db, id, newBody);
2023
2324
  return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
@@ -2205,20 +2506,17 @@ function cacheStats(db) {
2205
2506
  }
2206
2507
  function knownNodes(db) {
2207
2508
  const self = getNodeId(db);
2208
- const byId = new Map;
2209
- byId.set(self, getNodeLabel(db));
2210
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL").all();
2211
- for (const p of peers)
2212
- if (!byId.get(p.node_id))
2213
- byId.set(p.node_id, p.label ?? byId.get(p.node_id) ?? null);
2214
- const grants = db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all();
2215
- for (const g of grants)
2216
- if (!byId.has(g.node_id))
2217
- byId.set(g.node_id, null);
2218
- return [...byId.entries()].map(([nodeId, label]) => ({ nodeId, label, self: nodeId === self }));
2509
+ const ids = new Set([self]);
2510
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
2511
+ ids.add(r.id);
2512
+ for (const p of db.query("SELECT DISTINCT node_id FROM peers WHERE node_id IS NOT NULL").all())
2513
+ ids.add(p.node_id);
2514
+ for (const g of db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all())
2515
+ ids.add(g.node_id);
2516
+ return [...ids].map((nodeId) => ({ nodeId, label: nodeLabelOf(db, nodeId), self: nodeId === self }));
2219
2517
  }
2220
2518
  var POLICY_ID = "default", blobBytesResolver = null, setFullNodes = (db, nodeIds) => {
2221
- withChangeGroup(null, () => {
2519
+ withChangeGroup(db, null, () => {
2222
2520
  const seen = new Set;
2223
2521
  const list = nodeIds.filter((n) => n && !seen.has(n) && seen.add(n) != null);
2224
2522
  emit(db, "blob_policy", POLICY_ID, "full_nodes", list);
@@ -2281,7 +2579,7 @@ function migrateRecords(db) {
2281
2579
  db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
2282
2580
  if (!tableExists3(db, "record_values"))
2283
2581
  return;
2284
- const tx = db.transaction(() => {
2582
+ writeTx(db, () => {
2285
2583
  db.exec(`
2286
2584
  UPDATE records SET data = coalesce((
2287
2585
  SELECT json_group_object(rv.property_id, json(rv.value))
@@ -2292,7 +2590,6 @@ function migrateRecords(db) {
2292
2590
  db.exec("DROP TABLE record_values");
2293
2591
  backfillRecordOrderKeys(db);
2294
2592
  });
2295
- tx();
2296
2593
  backfillRecordOrderKeys(db);
2297
2594
  }
2298
2595
  function migrateShares(db) {
@@ -2350,7 +2647,7 @@ function migrateOplog(db) {
2350
2647
  function migrateCrdtChangesSeq(db) {
2351
2648
  if (hasColumn(db, "crdt_changes", "seq"))
2352
2649
  return;
2353
- const tx = db.transaction(() => {
2650
+ writeTx(db, () => {
2354
2651
  db.exec(`
2355
2652
  DROP TABLE IF EXISTS crdt_changes_new;
2356
2653
  CREATE TABLE crdt_changes_new (
@@ -2380,7 +2677,6 @@ function migrateCrdtChangesSeq(db) {
2380
2677
  if (tableExists3(db, "peers"))
2381
2678
  db.exec("UPDATE peers SET push_cursor = 0, pull_cursor = 0");
2382
2679
  });
2383
- tx();
2384
2680
  }
2385
2681
  function migrateStoragePeerUrls(db) {
2386
2682
  if (!tableExists3(db, "peers"))
@@ -2413,7 +2709,7 @@ function migrateStoragePeerUrls(db) {
2413
2709
  if (renames.length === 0)
2414
2710
  return;
2415
2711
  const map = new Map(renames.map(({ oldUrl, newUrl }) => [oldUrl, newUrl]));
2416
- const tx = db.transaction(() => {
2712
+ writeTx(db, () => {
2417
2713
  for (const { oldUrl, newUrl } of renames) {
2418
2714
  if (tableExists3(db, "storage_cursors"))
2419
2715
  db.query("UPDATE storage_cursors SET peer_url = ? WHERE peer_url = ?").run(newUrl, oldUrl);
@@ -2425,7 +2721,6 @@ function migrateStoragePeerUrls(db) {
2425
2721
  setFullNodes(db, fullNodes.map((n) => map.get(n) ?? n));
2426
2722
  }
2427
2723
  });
2428
- tx();
2429
2724
  }
2430
2725
  function migrateDatabases(db) {
2431
2726
  if (hasColumn(db, "databases", "meta"))
@@ -2468,20 +2763,27 @@ function migrateSitesAccess(db) {
2468
2763
  backfill("public_grants", "NULL");
2469
2764
  }
2470
2765
  }
2471
- function migrateSiteChannels(db) {
2472
- if (!tableExists3(db, "site_channels"))
2766
+ function replayDataset(db, dataset) {
2767
+ if (!tableExists3(db, dataset))
2473
2768
  return;
2474
- const wm = db.query("SELECT value FROM meta WHERE key = ?").get(SITE_CHANNELS_REPLAY_KEY);
2769
+ const key = `${dataset}_replay_seq`;
2770
+ const wm = db.query("SELECT value FROM meta WHERE key = ?").get(key);
2475
2771
  const since = wm ? Number(wm.value) : 0;
2476
2772
  const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
2477
- WHERE dataset = 'site_channels' AND seq > ? ORDER BY seq`).all(since);
2773
+ WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
2478
2774
  if (changes.length === 0)
2479
2775
  return;
2480
- db.transaction((rows) => {
2481
- for (const change of rows)
2776
+ writeTx(db, () => {
2777
+ for (const change of changes)
2482
2778
  applyChange(db, change);
2483
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SITE_CHANNELS_REPLAY_KEY, String(rows[rows.length - 1].seq));
2484
- })(changes);
2779
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, String(changes[changes.length - 1].seq));
2780
+ });
2781
+ }
2782
+ function migrateSiteChannels(db) {
2783
+ replayDataset(db, "site_channels");
2784
+ }
2785
+ function migrateNodes(db) {
2786
+ replayDataset(db, "nodes");
2485
2787
  }
2486
2788
  function initSchema(db) {
2487
2789
  runSchema(db);
@@ -2497,8 +2799,8 @@ function initSchema(db) {
2497
2799
  migrateShares(db);
2498
2800
  migrateSitesAccess(db);
2499
2801
  migrateSiteChannels(db);
2802
+ migrateNodes(db);
2500
2803
  }
2501
- var SITE_CHANNELS_REPLAY_KEY = "site_channels_replay_seq";
2502
2804
  var init_schema_init = __esm(() => {
2503
2805
  init_records();
2504
2806
  init_documents();
@@ -2949,7 +3251,7 @@ function writeFileRow(db, siteId, cleanPath, contentType, encoding, content) {
2949
3251
  return { ...same, changed: false };
2950
3252
  const existing = fileIdFor(db, siteId, cleanPath);
2951
3253
  const id = existing ?? newId("sf", cleanPath);
2952
- withChangeGroup(null, () => {
3254
+ withChangeGroup(db, null, () => {
2953
3255
  if (!existing) {
2954
3256
  const first = emit(db, "site_files", id, "site_id", siteId);
2955
3257
  emit(db, "site_files", id, "path", cleanPath);
@@ -8156,7 +8458,8 @@ var init_protocol = __esm(() => {
8156
8458
  SyncResponseSchema = object({
8157
8459
  node_id: string2().describe("Server's node id"),
8158
8460
  changes: array(ChangeSchema).describe("Server changes after `since`"),
8159
- cursor: number2().describe("New server cursor for the client to store")
8461
+ cursor: number2().describe("New server cursor for the client to store"),
8462
+ skewed_rejected: number2().optional().describe("Pushed changes the server refused for being stamped >5 min in the future")
8160
8463
  });
8161
8464
  HealthResponseSchema = object({
8162
8465
  ok: boolean2(),
@@ -8184,6 +8487,9 @@ function getPeer(db, url) {
8184
8487
  function setPeer(db, url, c) {
8185
8488
  db.query("INSERT INTO peers (url, pull_cursor, push_cursor) VALUES (?, ?, ?) ON CONFLICT(url) DO UPDATE SET pull_cursor = excluded.pull_cursor, push_cursor = excluded.push_cursor").run(url, c.pull_cursor, c.push_cursor);
8186
8489
  }
8490
+ function skewWarnings(source, skewed) {
8491
+ return Object.entries(skewed).map(([node, n]) => `${source}: refused ${n} change(s) from device ${node} stamped more than 5 min in the future \u2014 run \`mh repair --clock\` on that device`);
8492
+ }
8187
8493
  function advanceAckedPrefix(db, fromSeq, received) {
8188
8494
  if (received.length === 0)
8189
8495
  return fromSeq;
@@ -8222,12 +8528,21 @@ async function syncWithPeer(db, url, opts = {}) {
8222
8528
  throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `sync failed: ${res.status} ${await res.text()}`);
8223
8529
  const data = await res.json();
8224
8530
  const changes = data.changes ?? [];
8225
- const pulled = ingest(db, changes);
8531
+ const r = ingestDetailed(db, changes);
8532
+ const pulled = r.received;
8226
8533
  setPeer(db, url, {
8227
8534
  pull_cursor: data.cursor,
8228
8535
  push_cursor: advanceAckedPrefix(db, toPush.cursor, changes)
8229
8536
  });
8230
- return { pushed: toPush.changes.length, pulled, received: changes.length };
8537
+ const warnings = skewWarnings(url, r.skewed);
8538
+ if (data.skewed_rejected)
8539
+ warnings.push(`${url} refused ${data.skewed_rejected} of this device's change(s) as stamped more than 5 min in the future \u2014 run \`mh repair --clock\` here`);
8540
+ return {
8541
+ pushed: toPush.changes.length,
8542
+ pulled,
8543
+ received: changes.length,
8544
+ ...warnings.length ? { warnings } : {}
8545
+ };
8231
8546
  }
8232
8547
  var changeKey = (c) => `${c.hlc}\x00${c.node_id}\x00${c.dataset}\x00${c.row_id}\x00${c.col}`;
8233
8548
  var init_client = __esm(() => {
@@ -8726,9 +9041,10 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8726
9041
  }
8727
9042
  let snapKeys = [];
8728
9043
  let pulled = 0;
9044
+ const skewed = {};
8729
9045
  if (opts.pull ?? true) {
8730
9046
  snapKeys = (await client.list(snapshotRoot(base))).map((o) => o.key).filter((k) => k.endsWith(".snap"));
8731
- pulled += await pullSnapshots(db, peerUrl, snapKeys, client, key);
9047
+ pulled += await pullSnapshots(db, peerUrl, snapKeys, client, key, skewed);
8732
9048
  for (const remote of await listRemoteNodes(client, base)) {
8733
9049
  if (remote === node)
8734
9050
  continue;
@@ -8745,7 +9061,7 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8745
9061
  if (!bytes)
8746
9062
  continue;
8747
9063
  const changes = await decodeSegment(bytes, key);
8748
- pulled += ingest(db, changes);
9064
+ pulled += collectSkew(skewed, ingestDetailed(db, changes));
8749
9065
  setStorageCursor(db, peerUrl, remote, seg.key);
8750
9066
  }
8751
9067
  }
@@ -8772,9 +9088,15 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8772
9088
  if (ownCount >= threshold)
8773
9089
  await publishSnapshot(db, client, config, opts.snapshotRetainSegments);
8774
9090
  }
8775
- return { pushed, pulled, pendingPush };
9091
+ const warnings = skewWarnings(peerUrl, skewed);
9092
+ return { pushed, pulled, pendingPush, ...warnings.length ? { warnings } : {} };
8776
9093
  }
8777
- async function pullSnapshots(db, peerUrl, present, client, key) {
9094
+ function collectSkew(into, r) {
9095
+ for (const [n, k] of Object.entries(r.skewed))
9096
+ into[n] = (into[n] ?? 0) + k;
9097
+ return r.received;
9098
+ }
9099
+ async function pullSnapshots(db, peerUrl, present, client, key, skewed = {}) {
8778
9100
  if (present.length === 0)
8779
9101
  return 0;
8780
9102
  const consumed = getSnapshotConsumed(db, peerUrl);
@@ -8793,7 +9115,7 @@ async function pullSnapshots(db, peerUrl, present, client, key) {
8793
9115
  if (!bytes)
8794
9116
  continue;
8795
9117
  const changes = await decodeSegment(bytes, key);
8796
- pulled += ingest(db, changes);
9118
+ pulled += collectSkew(skewed, ingestDetailed(db, changes));
8797
9119
  consumed.add(k);
8798
9120
  for (const c of changes)
8799
9121
  if (!local[c.node_id] || c.hlc > local[c.node_id])
@@ -8814,6 +9136,7 @@ var init_storage = __esm(() => {
8814
9136
  init_crdt();
8815
9137
  init_publisher_lease();
8816
9138
  init_errors();
9139
+ init_client();
8817
9140
  init_e2ee();
8818
9141
  init_intent_retention();
8819
9142
  DEFAULT_SNAPSHOT_MAX_INTERVAL_MS = 30 * 60000;
@@ -9025,7 +9348,7 @@ var package_default;
9025
9348
  var init_package = __esm(() => {
9026
9349
  package_default = {
9027
9350
  name: "@tensorix/metahub",
9028
- version: "0.5.1",
9351
+ version: "0.5.3",
9029
9352
  description: "Metahub core library and CLI",
9030
9353
  license: "AGPL-3.0-only",
9031
9354
  type: "module",
@@ -9397,7 +9720,7 @@ function applyGuestIntent(db, policy, session, intent, opts) {
9397
9720
  kind: policy.audience,
9398
9721
  guestNode: session.guestNode
9399
9722
  };
9400
- const tx = db.transaction(() => {
9723
+ return writeTx(db, () => {
9401
9724
  const prepared = prepareIntent(db, policy, principal, intent, opts.clock);
9402
9725
  const txn = intentTxnPrefix(session.guestNode, intent.intentId) + prepared.fingerprint;
9403
9726
  const receipt = readReceipt(db, session.guestNode, intent.intentId, acceptedNow);
@@ -9417,7 +9740,6 @@ function applyGuestIntent(db, policy, session, intent, opts) {
9417
9740
  return applySubmitted(db, policy, principal, intent, prepared, txn, acceptedNow);
9418
9741
  return applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow);
9419
9742
  });
9420
- return tx();
9421
9743
  }
9422
9744
  function prepareIntent(db, policy, principal, intent, clock) {
9423
9745
  if (typeof intent.payload !== "object" || intent.payload === null || Array.isArray(intent.payload))
@@ -9455,7 +9777,7 @@ function prepareIntent(db, policy, principal, intent, clock) {
9455
9777
  };
9456
9778
  }
9457
9779
  function applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow) {
9458
- return withTxnId(txn, () => withNodeId(principal.guestNode, () => {
9780
+ return withTxnId(db, txn, () => withNodeId(principal.guestNode, () => {
9459
9781
  if (intent.action === "createRecord")
9460
9782
  assertGuestCreateCapacity(db, prepared.database, policy.limits);
9461
9783
  const current = intent.action === "createRecord" ? createRecordPrepared(db, prepared.database, prepared.cells) : updateRecordPrepared(db, intent.recordId, prepared.cells);
@@ -9579,7 +9901,7 @@ function isRecordRow(value) {
9579
9901
  }
9580
9902
  function clampMillis(submittedAt, now) {
9581
9903
  const safeNow = Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
9582
- const ceil = safeNow + HLC_SKEW_MS;
9904
+ const ceil = safeNow + HLC_MAX_SKEW_MS;
9583
9905
  if (!Number.isFinite(submittedAt))
9584
9906
  return safeNow;
9585
9907
  return Math.max(0, Math.min(Math.trunc(submittedAt), ceil));
@@ -9618,16 +9940,16 @@ function stableStringify(value) {
9618
9940
  }
9619
9941
  return JSON.stringify(value) ?? "null";
9620
9942
  }
9621
- var HLC_SKEW_MS, INTENT_ID_RE, RECEIPT_COL = "result";
9943
+ var INTENT_ID_RE, RECEIPT_COL = "result";
9622
9944
  var init_guest_intent = __esm(() => {
9623
9945
  init_errors();
9624
9946
  init_records();
9947
+ init_hlc();
9625
9948
  init_crdt();
9626
9949
  init_ids();
9627
9950
  init_intent_retention();
9628
9951
  init_grants_core();
9629
9952
  init_intent_retention();
9630
- HLC_SKEW_MS = 5 * 60000;
9631
9953
  INTENT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
9632
9954
  });
9633
9955
 
@@ -9635,6 +9957,7 @@ var init_guest_intent = __esm(() => {
9635
9957
  var ROOM_PROTOCOL_VERSION = 1, ROOM_BLOB_CHUNK_LIMIT, STATE_TABLE;
9636
9958
  var init_room_protocol = __esm(() => {
9637
9959
  init_errors();
9960
+ init_history_cache();
9638
9961
  init_crdt();
9639
9962
  init_schema_init();
9640
9963
  init_ids();
@@ -9736,13 +10059,13 @@ async function syncWithRoom(db, cfg, transport, opts = {}) {
9736
10059
  }
9737
10060
  assertGuestAuthored(resp.changes, cfg.guestBase);
9738
10061
  const pulled = ingest(db, resp.changes);
9739
- db.transaction(() => {
10062
+ writeTx(db, () => {
9740
10063
  applyPartitionDiff(db, cfg.peerKey, {
9741
10064
  entered: [...prep.shipped, ...pulledMemberRows(db, cfg, resp.changes)],
9742
10065
  left: prep.diff.left
9743
10066
  });
9744
10067
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: prep.pushCursor });
9745
- })();
10068
+ });
9746
10069
  if (resp.need_baseline.length > 0)
9747
10070
  await healBaselines(db, cfg, transport, resp.need_baseline);
9748
10071
  let healed = false;
@@ -9778,14 +10101,14 @@ async function healBaselines(db, cfg, transport, keys) {
9778
10101
  const resp = await roundTrip(db, cfg, transport, changes, evict);
9779
10102
  if (resp.share_state === "expired")
9780
10103
  return;
9781
- db.transaction(() => {
10104
+ writeTx(db, () => {
9782
10105
  applyPartitionDiff(db, cfg.peerKey, {
9783
10106
  entered: [...entered, ...pulledMemberRows(db, cfg, resp.changes)],
9784
10107
  left: evict
9785
10108
  });
9786
10109
  const cur = readCursors(db, cfg.peerKey);
9787
10110
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: cur.push });
9788
- })();
10111
+ });
9789
10112
  }
9790
10113
  async function fullReconcile(db, cfg, transport) {
9791
10114
  const members = computePartitionMembers(db, cfg.scope);
@@ -9793,11 +10116,11 @@ async function fullReconcile(db, cfg, transport) {
9793
10116
  const resp = await roundTrip(db, cfg, transport, winners, [], members);
9794
10117
  if (resp.share_state === "expired")
9795
10118
  return;
9796
- db.transaction(() => {
10119
+ writeTx(db, () => {
9797
10120
  resetPartitionShadow(db, cfg.peerKey, members);
9798
10121
  const cur = readCursors(db, cfg.peerKey);
9799
10122
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: cur.push });
9800
- })();
10123
+ });
9801
10124
  }
9802
10125
  async function roundTrip(db, cfg, transport, changes, evict, members) {
9803
10126
  const resp = await transport({
@@ -10199,7 +10522,7 @@ function updatePublicSiteChannelUrls(db, siteId, siteName) {
10199
10522
  return changed;
10200
10523
  }
10201
10524
  function applySiteUpdate(db, siteId, opts, hooks) {
10202
- return db.transaction(() => withChangeGroup("site.update", () => {
10525
+ return withChangeGroup(db, "site.update", () => {
10203
10526
  const updated = updateSite(db, siteId, {
10204
10527
  ...opts,
10205
10528
  visibility: opts.visibility === "public" ? "private" : opts.visibility
@@ -10211,15 +10534,15 @@ function applySiteUpdate(db, siteId, opts, hooks) {
10211
10534
  if (opts.name !== undefined)
10212
10535
  updatePublicSiteChannelUrls(db, siteId, updated.name);
10213
10536
  return getSite(db, siteId);
10214
- }))();
10537
+ });
10215
10538
  }
10216
10539
  function applySiteDelete(db, siteId) {
10217
- return db.transaction(() => withChangeGroup("site.delete", () => {
10540
+ return withChangeGroup(db, "site.delete", () => {
10218
10541
  if (!deleteSite(db, siteId))
10219
10542
  return false;
10220
10543
  revokeAllSiteChannels(db, siteId);
10221
10544
  return true;
10222
- }))();
10545
+ });
10223
10546
  }
10224
10547
  function revokeAllSiteChannels(db, siteId) {
10225
10548
  const active = listSiteChannelRows(db, siteId).filter((channel) => channel.desired_state === "active");
@@ -10344,7 +10667,7 @@ var init_site_channel_store = __esm(() => {
10344
10667
  policy_json: input.policy == null ? null : JSON.stringify(input.policy),
10345
10668
  desired_state: input.desiredState ?? "active"
10346
10669
  };
10347
- db.transaction(() => {
10670
+ writeTx(db, () => {
10348
10671
  if (!existing) {
10349
10672
  const first = emit(db, "site_channels", id, "site_id", input.siteId);
10350
10673
  emit(db, "site_channels", id, "created_hlc", first.hlc);
@@ -10354,7 +10677,7 @@ var init_site_channel_store = __esm(() => {
10354
10677
  continue;
10355
10678
  emit(db, "site_channels", id, col, value);
10356
10679
  }
10357
- })();
10680
+ });
10358
10681
  return getSiteChannelRow(db, id);
10359
10682
  });
10360
10683
  setSiteChannelDesiredState = grouped(function setSiteChannelDesiredState(db, id, desired) {
@@ -10527,6 +10850,8 @@ async function addAndSyncStoragePeer(db, spec) {
10527
10850
  publish: spec.publish,
10528
10851
  priority: spec.priority
10529
10852
  };
10853
+ if (spec.provider)
10854
+ config.provider = spec.provider;
10530
10855
  if (encrypt) {
10531
10856
  if (spec.recoveryCode) {
10532
10857
  const rawKey = await decodeRecoveryCode(spec.recoveryCode);
@@ -10630,14 +10955,13 @@ async function rotateStoragePeer(db, url, input) {
10630
10955
  };
10631
10956
  }
10632
10957
  function removePeer(db, url) {
10633
- const tx = db.transaction(() => {
10958
+ return writeTx(db, () => {
10634
10959
  const changed = db.query("DELETE FROM peers WHERE url = ?").run(url).changes > 0;
10635
10960
  db.query("DELETE FROM peer_grants WHERE peer_url = ?").run(url);
10636
10961
  db.query("DELETE FROM storage_cursors WHERE peer_url = ?").run(url);
10637
10962
  db.query("DELETE FROM room_rows WHERE peer_key = ?").run(url);
10638
10963
  return changed;
10639
10964
  });
10640
- return tx();
10641
10965
  }
10642
10966
  function setPeerEnabled(db, url, enabled) {
10643
10967
  return db.query("UPDATE peers SET enabled = ? WHERE url = ?").run(enabled ? 1 : 0, url).changes > 0;
@@ -10712,7 +11036,7 @@ async function syncPeerOnce(db, url, opts) {
10712
11036
  result = await syncWithPeer(db, url, { timeoutMs: opts?.timeoutMs });
10713
11037
  }
10714
11038
  updatePeerStatus(db, url, "ok", null);
10715
- const warnings = [];
11039
+ const warnings = [...result.warnings ?? []];
10716
11040
  try {
10717
11041
  if (peer?.kind !== "s3" && peer?.kind !== "room")
10718
11042
  clearCoveredSiteRollbacks(db, url);
@@ -10881,20 +11205,20 @@ function normalizePublicBaseUrl(input) {
10881
11205
  try {
10882
11206
  parsed = new URL(input.trim());
10883
11207
  } catch {
10884
- throw new Error("\u8BF7\u8F93\u5165\u5B8C\u6574\u7684 http(s) \u5730\u5740");
11208
+ throw new MhError("invalid_input", "\u8BF7\u8F93\u5165\u5B8C\u6574\u7684 http(s) \u5730\u5740");
10885
11209
  }
10886
11210
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
10887
- throw new Error("\u7AD9\u70B9\u5165\u53E3\u53EA\u652F\u6301 HTTP \u6216 HTTPS");
11211
+ throw new MhError("invalid_input", "\u7AD9\u70B9\u5165\u53E3\u53EA\u652F\u6301 HTTP \u6216 HTTPS");
10888
11212
  if (parsed.username || parsed.password || parsed.search || parsed.hash)
10889
- throw new Error("\u7AD9\u70B9\u5165\u53E3\u4E0D\u80FD\u5305\u542B\u51ED\u636E\u3001\u67E5\u8BE2\u53C2\u6570\u6216\u7247\u6BB5");
11213
+ throw new MhError("invalid_input", "\u7AD9\u70B9\u5165\u53E3\u4E0D\u80FD\u5305\u542B\u51ED\u636E\u3001\u67E5\u8BE2\u53C2\u6570\u6216\u7247\u6BB5");
10890
11214
  if (parsed.pathname !== "/" && parsed.pathname !== "")
10891
- throw new Error("\u7AD9\u70B9\u5165\u53E3\u5FC5\u987B\u662F\u57DF\u540D\u6839\u5730\u5740\uFF0C\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84");
11215
+ throw new MhError("invalid_input", "\u7AD9\u70B9\u5165\u53E3\u5FC5\u987B\u662F\u57DF\u540D\u6839\u5730\u5740\uFF0C\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84");
10892
11216
  const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
10893
11217
  const scope = hostScope(host);
10894
11218
  if (scope === "invalid")
10895
- throw new Error("\u7AD9\u70B9\u5165\u53E3\u5FC5\u987B\u662F\u53EF\u8BBF\u95EE\u7684\u4E3B\u673A\u5730\u5740");
11219
+ throw new MhError("invalid_input", "\u7AD9\u70B9\u5165\u53E3\u5FC5\u987B\u662F\u53EF\u8BBF\u95EE\u7684\u4E3B\u673A\u5730\u5740");
10896
11220
  if (parsed.protocol === "http:" && scope === "public")
10897
- throw new Error("\u516C\u7F51\u5165\u53E3\u5FC5\u987B\u4F7F\u7528 HTTPS\uFF1BHTTP \u4EC5\u5141\u8BB8\u672C\u673A\u6216\u5C40\u57DF\u7F51\u5730\u5740");
11221
+ throw new MhError("invalid_input", "\u516C\u7F51\u5165\u53E3\u5FC5\u987B\u4F7F\u7528 HTTPS\uFF1BHTTP \u4EC5\u5141\u8BB8\u672C\u673A\u6216\u5C40\u57DF\u7F51\u5730\u5740");
10898
11222
  parsed.pathname = "";
10899
11223
  const url = parsed.toString().replace(/\/+$/, "");
10900
11224
  return { url, scope };
@@ -10942,6 +11266,7 @@ function setServerConfig(db, partial) {
10942
11266
  var MAX_BLOB_UPLOAD_BYTES, K_HOST = "cfg_host", K_PORT = "cfg_port", K_SYNC_INTERVAL = "cfg_sync_interval", K_AUTO_SYNC = "cfg_auto_sync", K_BLOB_QUOTA = "cfg_blob_quota", K_PUBLIC_BASE_URL = "cfg_public_base_url", DEFAULT_CONFIG;
10943
11267
  var init_config = __esm(() => {
10944
11268
  init_token();
11269
+ init_errors();
10945
11270
  MAX_BLOB_UPLOAD_BYTES = Number(process.env.METAHUB_MAX_BLOB_UPLOAD) || 100 * 1024 * 1024;
10946
11271
  DEFAULT_CONFIG = {
10947
11272
  host: "127.0.0.1",
@@ -11966,6 +12291,8 @@ function redeemPairingCode(db, code) {
11966
12291
  }
11967
12292
  function mintGrant(db, peerUrl, nodeId) {
11968
12293
  const token = randomSuffix(32);
12294
+ if (nodeId)
12295
+ db.query("DELETE FROM peer_grants WHERE node_id = ?").run(nodeId);
11969
12296
  db.query("INSERT INTO peer_grants (token, peer_url, node_id, created_at) VALUES (?, ?, ?, ?)").run(token, peerUrl, nodeId, Date.now());
11970
12297
  return token;
11971
12298
  }
@@ -12163,10 +12490,14 @@ function listDevices(db) {
12163
12490
  const ensure = (nodeId) => {
12164
12491
  let v = byNode.get(nodeId);
12165
12492
  if (!v) {
12493
+ const meta = readNodeMeta(db, nodeId);
12166
12494
  v = {
12167
12495
  nodeId,
12168
- label: null,
12496
+ label: nodeLabelOf(db, nodeId),
12169
12497
  self: nodeId === self,
12498
+ platform: meta?.platform ?? null,
12499
+ form: meta?.form ?? null,
12500
+ app: meta?.app ?? null,
12170
12501
  channels: [],
12171
12502
  lastActivityAt: null,
12172
12503
  revocable: "none",
@@ -12177,7 +12508,9 @@ function listDevices(db) {
12177
12508
  }
12178
12509
  return v;
12179
12510
  };
12180
- ensure(self).label = getNodeLabel(db);
12511
+ ensure(self);
12512
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
12513
+ ensure(r.id);
12181
12514
  const oplog = db.query("SELECT node_id, MAX(hlc) AS h FROM crdt_changes GROUP BY node_id").all();
12182
12515
  for (const r of oplog)
12183
12516
  ensure(r.node_id).channels.push({ kind: "oplog", ref: "", lastSeenAt: parseHlc(r.h).millis });
@@ -12186,8 +12519,6 @@ function listDevices(db) {
12186
12519
  if (!p.node_id)
12187
12520
  continue;
12188
12521
  const v = ensure(p.node_id);
12189
- if (!v.label)
12190
- v.label = p.label;
12191
12522
  v.channels.push({
12192
12523
  kind: "paired_out",
12193
12524
  ref: p.url,
@@ -12205,6 +12536,9 @@ function listDevices(db) {
12205
12536
  nodeId: null,
12206
12537
  label: null,
12207
12538
  self: false,
12539
+ platform: null,
12540
+ form: null,
12541
+ app: null,
12208
12542
  channels: [ch],
12209
12543
  lastActivityAt: g.created_at,
12210
12544
  revocable: "yes",
@@ -12293,6 +12627,7 @@ async function refreshBucketPresence(db, url) {
12293
12627
  }
12294
12628
  var init_devices = __esm(() => {
12295
12629
  init_errors();
12630
+ init_hlc();
12296
12631
  init_node();
12297
12632
  init_peers();
12298
12633
  init_pairing();
@@ -12324,7 +12659,8 @@ function s3PeerViews(db) {
12324
12659
  prefix: c.prefix ?? null,
12325
12660
  accessKeyId: c.accessKeyId ?? null,
12326
12661
  encrypt: c.encrypt !== false,
12327
- virtualHostedStyle: c.virtualHostedStyle ?? null
12662
+ virtualHostedStyle: c.virtualHostedStyle ?? null,
12663
+ provider: c.provider ?? null
12328
12664
  };
12329
12665
  });
12330
12666
  }
@@ -12401,7 +12737,8 @@ var init_peers_routes = __esm(() => {
12401
12737
  prefix: string2().optional(),
12402
12738
  encrypt: boolean2().optional(),
12403
12739
  passphrase: string2().optional(),
12404
- corsOrigins: array(string2()).optional()
12740
+ corsOrigins: array(string2()).optional(),
12741
+ provider: string2().optional()
12405
12742
  });
12406
12743
  S3PeerSchema = object({
12407
12744
  url: string2(),
@@ -12418,7 +12755,8 @@ var init_peers_routes = __esm(() => {
12418
12755
  prefix: string2().nullable(),
12419
12756
  accessKeyId: string2().nullable(),
12420
12757
  encrypt: boolean2(),
12421
- virtualHostedStyle: boolean2().nullable()
12758
+ virtualHostedStyle: boolean2().nullable(),
12759
+ provider: string2().nullable()
12422
12760
  });
12423
12761
  DataPlaceSchema = object({
12424
12762
  kind: _enum(["self", "device", "bucket"]),
@@ -12836,12 +13174,171 @@ var init_blob_routes = __esm(() => {
12836
13174
  ];
12837
13175
  });
12838
13176
 
12839
- // src/core/sync/routes.ts
12840
- function errorResponse(e) {
13177
+ // src/core/sync/locale.ts
13178
+ function localeFromTags(tags) {
13179
+ if (!tags || tags.length === 0)
13180
+ return "zh-CN";
13181
+ for (const tag of tags) {
13182
+ if (!tag)
13183
+ continue;
13184
+ if (/^zh\b/i.test(tag))
13185
+ return "zh-CN";
13186
+ if (/^en\b/i.test(tag))
13187
+ return "en";
13188
+ }
13189
+ return "en";
13190
+ }
13191
+ function localeFromAcceptLanguage(header) {
13192
+ if (!header)
13193
+ return "zh-CN";
13194
+ return localeFromTags(header.split(",").map((s) => s.split(";")[0].trim()));
13195
+ }
13196
+ function localeFromCookie(header) {
13197
+ if (!header)
13198
+ return null;
13199
+ for (const part of header.split(";")) {
13200
+ const eq = part.indexOf("=");
13201
+ if (eq < 0)
13202
+ continue;
13203
+ if (part.slice(0, eq).trim() !== LANG_COOKIE)
13204
+ continue;
13205
+ const v = decodeURIComponent(part.slice(eq + 1).trim());
13206
+ return isLocale(v) ? v : null;
13207
+ }
13208
+ return null;
13209
+ }
13210
+ function pickLocale(req) {
13211
+ return localeFromCookie(req.headers.get("cookie")) ?? localeFromAcceptLanguage(req.headers.get("accept-language"));
13212
+ }
13213
+ var LANG_COOKIE = "mh-lang", isLocale = (v) => v === "zh-CN" || v === "en";
13214
+
13215
+ // src/core/sync/page-messages.ts
13216
+ function pm(locale, key, params) {
13217
+ const s = locale === "en" ? EN[key] : key;
13218
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
13219
+ }
13220
+ function pmIfKnown(locale, text, params) {
13221
+ if (locale === "en" && text in EN)
13222
+ return pm(locale, text, params);
13223
+ return params ? pm(locale, text, params) : text;
13224
+ }
13225
+ function localizeError(locale, e) {
13226
+ if (e instanceof MhError && e.i18n)
13227
+ return pmIfKnown(locale, e.i18n.key, e.i18n.params);
12841
13228
  const message = e instanceof Error ? e.message : String(e);
13229
+ return pmIfKnown(locale, message);
13230
+ }
13231
+ var EN;
13232
+ var init_page_messages = __esm(() => {
13233
+ init_errors();
13234
+ EN = {
13235
+ "\u672C\u673A\u903B\u8F91\u65F6\u949F\u6BD4\u7CFB\u7EDF\u65F6\u95F4\u8D85\u524D {min} \u5206\u949F\uFF08\u7CFB\u7EDF\u65F6\u949F\u66FE\u88AB\u8C03\u9519\uFF0C\u6216\u6536\u5230\u8FC7\u65F6\u949F\u9519\u8BEF\u8BBE\u5907\u7684\u6570\u636E\uFF09\uFF0C\u8FD0\u884C mh repair --clock \u4FEE\u590D": "This device's logical clock is {min} min ahead of wall time (the system clock was wrong, or data arrived from a device with a wrong clock); run mh repair --clock",
13236
+ "\u6570\u636E\u5E93\u6B63\u88AB\u53E6\u4E00\u4E2A\u8FDB\u7A0B\u5199\u5165\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5": "The database is being written by another process; retry shortly",
13237
+ "\u901A\u8FC7 metahub \u5206\u4EAB": "Shared via metahub",
13238
+ "\u7531 metahub \u6258\u7BA1": "Hosted by metahub",
13239
+ "\uFF08\u7A7A\u6587\u6863\uFF09": "(empty document)",
13240
+ \u{6587}\u{6863}: "Document",
13241
+ \u{8868}\u{683c}: "Table",
13242
+ \u{7f16}\u{8f91}: "Edit",
13243
+ \u{7f16}\u{8f91}\u{6587}\u{6863}: "Edit document",
13244
+ \u{4fdd}\u{5b58}: "Save",
13245
+ "\u4FDD\u5B58\u4E2D\u2026": "Saving\u2026",
13246
+ \u{4fdd}\u{5b58}\u{5931}\u{8d25}: "Save failed",
13247
+ "\u5DF2\u4FDD\u5B58 \u2713": "Saved \u2713",
13248
+ "\u53EF\u7F16\u8F91\u6587\u672C/\u6570\u5B57/URL/\u65E5\u671F\u5355\u5143\u683C\uFF0C\u5931\u7126\u81EA\u52A8\u4FDD\u5B58": "Text, number, URL and date cells are editable; changes save on blur",
13249
+ "\u7F16\u8F91\u7AD9\u70B9\u6587\u4EF6\uFF08\u6587\u672C\u6587\u4EF6\u53EF\u5728\u7EBF\u7F16\u8F91\uFF1B\u4FDD\u5B58\u5373\u5BF9\u6240\u6709\u8BBF\u95EE\u8005\u751F\u6548\uFF09\u3002": "Edit site files (text files can be edited here; saving applies to every visitor).",
13250
+ "\uFF08\u6682\u65E0\u6587\u4EF6\uFF09": "(no files yet)",
13251
+ "\u9884\u89C8 \u2197": "Preview \u2197",
13252
+ "\uFF08\u4E8C\u8FDB\u5236\u6587\u4EF6\uFF0C\u4E0D\u53EF\u5728\u7EBF\u7F16\u8F91\uFF09": "(binary file, cannot be edited here)",
13253
+ "{name} \xB7 \u6587\u4EF6": "{name} \xB7 Files",
13254
+ "\uD83D\uDD12 \u6B64\u5206\u4EAB\u53D7\u53E3\u4EE4\u4FDD\u62A4": "\uD83D\uDD12 This share is protected by a passcode",
13255
+ "\u53E3\u4EE4\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002": "Wrong passcode, try again.",
13256
+ \u{53e3}\u{4ee4}: "Passcode",
13257
+ \u{89e3}\u{9501}: "Unlock",
13258
+ \u{53d7}\u{4fdd}\u{62a4}\u{7684}\u{5206}\u{4eab}: "Protected share",
13259
+ \u{590d}\u{5236}\u{5168}\u{6587}: "Copy all",
13260
+ \u{5df2}\u{590d}\u{5236}: "Copied",
13261
+ \u{590d}\u{5236}\u{5931}\u{8d25}: "Copy failed",
13262
+ \u{7ad9}\u{70b9}\u{5c1a}\u{672a}\u{540c}\u{6b65}: "Site not synced yet",
13263
+ "\u5206\u4EAB\u65B9\u8BBE\u5907\u4E0A\u7EBF\u540C\u6B65\u540E\u5373\u53EF\u8BBF\u95EE\u3002": "Available once the sharing device comes online and syncs.",
13264
+ \u{9875}\u{9762}\u{4e0d}\u{5b58}\u{5728}: "Page not found",
13265
+ "\u8FD9\u4E2A\u7AD9\u70B9\u91CC\u6CA1\u6709\u8FD9\u4E2A\u9875\u9762\u6216\u6587\u4EF6\u3002": "This site has no such page or file.",
13266
+ \u{8d44}\u{6e90}\u{6682}\u{4e0d}\u{53ef}\u{7528}: "Resource temporarily unavailable",
13267
+ "\u6587\u4EF6\u5185\u5BB9\u5C1A\u672A\u540C\u6B65\u5230\u623F\u95F4\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "The file content has not synced to the room yet. Try again later.",
13268
+ "\u6587\u4EF6\u7684\u5185\u5BB9\u6682\u65F6\u65E0\u6CD5\u53D6\u5F97\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "The file content cannot be retrieved right now. Try again later.",
13269
+ \u{7ad9}\u{70b9}\u{4e0d}\u{5b58}\u{5728}: "Site not found",
13270
+ "\u6CA1\u6709\u540D\u4E3A \u201C{name}\u201D \u7684\u7AD9\u70B9\u3002": "There is no site named \u201C{name}\u201D.",
13271
+ \u{8fd4}\u{56de}\u{7ad9}\u{70b9}\u{9996}\u{9875}: "Back to site home",
13272
+ "\u9A8C\u8BC1\u8BBF\u95EE\u4EE4\u724C \xB7 metahub": "Verify access token \xB7 metahub",
13273
+ \u{8f93}\u{5165}\u{8bbf}\u{95ee}\u{4ee4}\u{724c}: "Enter the access token",
13274
+ "\u6B64\u670D\u52A1\u5668\u5DF2\u5F00\u542F\u8BBF\u95EE\u4FDD\u62A4\u3002\u5728\u5DF2\u767B\u5F55\u8BBE\u5907\u7684\u300C\u8BBE\u7F6E \u2192 \u8BBE\u5907\u4E0E\u6388\u6743\u300D\u83B7\u53D6\u767B\u5F55\u94FE\u63A5\u6216\u4E8C\u7EF4\u7801,\u6216\u5728\u670D\u52A1\u5668\u4E0A\u8FD0\u884C {cmd}\u3002": "This server requires an access token. Get a login link or QR code from Settings \u2192 Devices on a signed-in device, or run {cmd} on the server.",
13275
+ \u{7c98}\u{8d34}\u{8bbf}\u{95ee}\u{4ee4}\u{724c}\u{6216}\u{767b}\u{5f55}\u{94fe}\u{63a5}: "Paste the access token or login link",
13276
+ \u{663e}\u{793a}\u{4ee4}\u{724c}: "Show token",
13277
+ \u{9690}\u{85cf}\u{4ee4}\u{724c}: "Hide token",
13278
+ \u{89e3}\u{9501}\u{5e76}\u{8fdb}\u{5165}: "Unlock and enter",
13279
+ "\u8BF7\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C\u3002": "Enter the access token.",
13280
+ "\u9A8C\u8BC1\u4E2D\u2026": "Verifying\u2026",
13281
+ "\u4EE4\u724C\u65E0\u6548,\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5\u3002": "Invalid token. Check it and try again.",
13282
+ "\u767B\u5F55\u5DF2\u8FC7\u671F,\u8BF7\u91CD\u65B0\u8F93\u5165\u4EE4\u724C\u3002": "Session expired. Enter the token again.",
13283
+ \u{9644}\u{4ef6}\u{5df2}\u{5931}\u{6548}: "Attachment unavailable",
13284
+ \u{94fe}\u{63a5}\u{7f3a}\u{5c11}\u{5206}\u{4eab}\u{6570}\u{636e}: "The link is missing share data",
13285
+ \u{8bf7}\u{8f93}\u{5165}\u{5206}\u{4eab}\u{53e3}\u{4ee4}: "Enter the share passcode",
13286
+ \u{94fe}\u{63a5}\u{7f3a}\u{5c11}\u{5bc6}\u{94a5}: "The link is missing its key",
13287
+ \u{53e3}\u{4ee4}\u{9519}\u{8bef}\u{6216}\u{94fe}\u{63a5}\u{5df2}\u{5931}\u{6548}: "Wrong passcode or the link has expired",
13288
+ \u{94fe}\u{63a5}\u{5df2}\u{5931}\u{6548}\u{6216}\u{5bc6}\u{94a5}\u{4e0d}\u{6b63}\u{786e}: "The link has expired or the key is wrong",
13289
+ \u{5206}\u{4eab}: "Share",
13290
+ \u{6b63}\u{5728}\u{52a0}\u{8f7d}: "Loading",
13291
+ "\u2705 \u5DF2\u6388\u6743": "\u2705 Authorized",
13292
+ "\u53EF\u4EE5\u5173\u95ED\u6B64\u6807\u7B7E\u9875\uFF0C\u56DE\u5230 MetaHub \u7EE7\u7EED\u90E8\u7F72\u3002": "You can close this tab and return to MetaHub to continue deploying.",
13293
+ \u{7ad9}\u{70b9}\u{5165}\u{53e3}\u{5065}\u{5eb7}\u{54cd}\u{5e94}\u{8fc7}\u{5927}: "Site entry health response is too large",
13294
+ \u{7ad9}\u{70b9}\u{5165}\u{53e3}\u{4e0d}\u{80fd}\u{6307}\u{5411}\u{4e91}\u{5e73}\u{53f0}\u{5143}\u{6570}\u{636e}\u{670d}\u{52a1}: "Site entry must not point at a cloud metadata service",
13295
+ "Desktop sidecar \u4EC5\u7528\u4E8E\u672C\u673A\u9884\u89C8\uFF1B\u8BF7\u542F\u52A8\u5E26\u9274\u6743\u7684 mh --server\uFF0C\u6216\u4F7F\u7528 Edge": "The desktop sidecar is for local preview only; start an authenticated mh --server, or use Edge",
13296
+ "\u7AD9\u70B9\u5165\u53E3\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF08HTTP {status}\uFF09": "Site entry health check failed (HTTP {status})",
13297
+ \u{7ad9}\u{70b9}\u{5165}\u{53e3}\u{5065}\u{5eb7}\u{54cd}\u{5e94}\u{65e0}\u{6548}: "Site entry health response is invalid",
13298
+ "\u7AD9\u70B9\u5165\u53E3\u8282\u70B9\u4E0D\u5339\u914D\uFF08\u671F\u671B {expected}\uFF0C\u5B9E\u9645 {actual}\uFF09\uFF1B\u8FD9\u662F\u9632\u8BEF\u914D\u68C0\u67E5\uFF0C\u4E0D\u662F\u8EAB\u4EFD\u8BA4\u8BC1": "Site entry node mismatch (expected {expected}, got {actual}); this is a misconfiguration guard, not authentication",
13299
+ "\u76EE\u6807\u8BBE\u5907\u7248\u672C\u4E0D\u652F\u6301\u5B9A\u5411\u53D1\u5E03\u6E20\u9053\uFF1B\u8BF7\u5148\u5347\u7EA7\u8BE5\u8BBE\u5907\uFF0C\u518D\u91CD\u65B0\u9A8C\u8BC1\u5165\u53E3": "The target device does not support targeted publishing channels; upgrade it, then verify the entry again",
13300
+ "\u65E0\u6CD5\u9A8C\u8BC1\u7AD9\u70B9\u5165\u53E3\uFF1A\u8FDE\u63A5\u8D85\u65F6": "Could not verify the site entry: connection timed out",
13301
+ "\u65E0\u6CD5\u9A8C\u8BC1\u7AD9\u70B9\u5165\u53E3\uFF1A{msg}": "Could not verify the site entry: {msg}",
13302
+ "\u8BF7\u5148\u5728\u201C\u8BBE\u7F6E \u2192 \u7AD9\u70B9\u6258\u7BA1\u201D\u914D\u7F6E\u5E76\u9A8C\u8BC1\u5F53\u524D\u8BBE\u5907\u7684\u516C\u7F51\u6216\u5C40\u57DF\u7F51\u5165\u53E3": "Configure and verify this device's public or LAN entry under Settings \u2192 Site hosting first",
13303
+ "\u914D\u5BF9\u8BBE\u5907\u7F3A\u5C11\u8282\u70B9\u8EAB\u4EFD\uFF0C\u91CD\u65B0\u914D\u5BF9\u540E\u518D\u8BD5": "The paired device has no node identity; pair again and retry",
13304
+ "\u8BE5\u7AD9\u70B9\u4ECD\u5728\u7B49\u5F85\u76EE\u6807\u8BBE\u5907\u786E\u8BA4\u56DE\u6EDA\uFF0C\u8BF7\u5148\u91CD\u8BD5\u56DE\u6EDA": "This site is still waiting for the target device to confirm the rollback; retry the rollback first",
13305
+ \u{914d}\u{5bf9}\u{8bbe}\u{5907}\u{540c}\u{6b65}\u{5931}\u{8d25}: "Sync with the paired device failed",
13306
+ \u{6ca1}\u{6709}\u{5f85}\u{786e}\u{8ba4}\u{7684}\u{7ad9}\u{70b9}\u{56de}\u{6eda}: "No site rollback is awaiting confirmation",
13307
+ "\u76EE\u6807\u914D\u5BF9\u8BBE\u5907\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u914D\u5BF9\u540E\u91CD\u8BD5": "The target paired device does not exist; pair again and retry",
13308
+ \u{7ad9}\u{70b9}\u{6e20}\u{9053}\u{5df2}\u{4e0d}\u{5b58}\u{5728}: "The site channel no longer exists",
13309
+ "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55": "The OAuth flow does not exist or has expired; sign in again",
13310
+ "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5": "The OAuth flow does not exist or has expired; try again",
13311
+ "Cloudflare \u6388\u6743\u5931\u8D25": "Cloudflare authorization failed",
13312
+ "Cloudflare \u6388\u6743\u5C1A\u672A\u5B8C\u6210\uFF0C\u8BF7\u7A0D\u5019": "Cloudflare authorization is not finished yet; please wait",
13313
+ "\u7F3A\u5C11 Cloudflare \u8D26\u53F7 id": "Missing Cloudflare account id",
13314
+ "\u7F3A\u5C11 Cloudflare \u51ED\u636E\uFF08\u8BF7\u5148\u767B\u5F55\u6216\u63D0\u4F9B API Token\uFF09": "Missing Cloudflare credentials (sign in first or provide an API token)",
13315
+ "Cloudflare \u6388\u6743\u5931\u8D25\uFF1A{detail}": "Cloudflare authorization failed: {detail}",
13316
+ "\u672A\u8FD4\u56DE access token": "no access token returned",
13317
+ "\u65E0\u6CD5\u8FDE\u63A5 Cloudflare \u6388\u6743\u670D\u52A1\uFF1A{msg}": "Could not reach the Cloudflare authorization service: {msg}",
13318
+ "Cloudflare \u4EE4\u724C\u4EA4\u6362\u5931\u8D25\uFF08HTTP {status}\uFF09": "Cloudflare token exchange failed (HTTP {status})",
13319
+ "\u65E0\u6CD5\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5217\u8868\uFF1A{msg}": "Could not read the Cloudflare account list: {msg}",
13320
+ "\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5931\u8D25\uFF08HTTP {status}\uFF09": "Reading Cloudflare accounts failed (HTTP {status})",
13321
+ "\u672A\u914D\u7F6E Cloudflare OAuth \u5BA2\u6237\u7AEF\uFF0C\u8BF7\u6539\u7528 API Token \u90E8\u7F72": "No Cloudflare OAuth client is configured; deploy with an API token instead",
13322
+ "Cloudflare \u6388\u6743\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5": "Cloudflare authorization timed out; try again",
13323
+ "Cloudflare \u6388\u6743\u88AB\u62D2\u7EDD\uFF1A{detail}": "Cloudflare authorization was denied: {detail}",
13324
+ "OAuth state \u4E0D\u5339\u914D\uFF0C\u7591\u4F3C\u4F2A\u9020\u56DE\u8C03\uFF0C\u5DF2\u4E2D\u6B62": "OAuth state mismatch; the callback looks forged, aborted",
13325
+ "Cloudflare \u672A\u8FD4\u56DE\u6388\u6743\u7801": "Cloudflare did not return an authorization code",
13326
+ "\u8BF7\u8F93\u5165\u5B8C\u6574\u7684 http(s) \u5730\u5740": "Enter a complete http(s) address",
13327
+ "\u7AD9\u70B9\u5165\u53E3\u53EA\u652F\u6301 HTTP \u6216 HTTPS": "Site entry supports HTTP or HTTPS only",
13328
+ "\u7AD9\u70B9\u5165\u53E3\u4E0D\u80FD\u5305\u542B\u51ED\u636E\u3001\u67E5\u8BE2\u53C2\u6570\u6216\u7247\u6BB5": "Site entry must not contain credentials, query parameters or fragments",
13329
+ "\u7AD9\u70B9\u5165\u53E3\u5FC5\u987B\u662F\u57DF\u540D\u6839\u5730\u5740\uFF0C\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84": "Site entry must be a domain root without a path",
13330
+ \u{7ad9}\u{70b9}\u{5165}\u{53e3}\u{5fc5}\u{987b}\u{662f}\u{53ef}\u{8bbf}\u{95ee}\u{7684}\u{4e3b}\u{673a}\u{5730}\u{5740}: "Site entry must be a reachable host address",
13331
+ "\u516C\u7F51\u5165\u53E3\u5FC5\u987B\u4F7F\u7528 HTTPS\uFF1BHTTP \u4EC5\u5141\u8BB8\u672C\u673A\u6216\u5C40\u57DF\u7F51\u5730\u5740": "A public entry must use HTTPS; HTTP is only allowed for local or LAN addresses"
13332
+ };
13333
+ });
13334
+
13335
+ // src/core/sync/routes.ts
13336
+ function errorResponse(e, req) {
13337
+ const message = req ? localizeError(pickLocale(req), e) : e instanceof Error ? e.message : String(e);
12842
13338
  const code = errorCode(e);
12843
13339
  return Response.json(code ? { error: message, code } : { error: message }, {
12844
- status: code ? HTTP_STATUS[code] : 400
13340
+ status: code ? HTTP_STATUS[code] : 400,
13341
+ ...code === "busy" ? { headers: { "retry-after": "1" } } : {}
12845
13342
  });
12846
13343
  }
12847
13344
  var HTTP_STATUS, syncRoutes, routes;
@@ -12852,6 +13349,7 @@ var init_routes = __esm(() => {
12852
13349
  init_peers_routes();
12853
13350
  init_blob_routes();
12854
13351
  init_errors();
13352
+ init_page_messages();
12855
13353
  init_package();
12856
13354
  HTTP_STATUS = {
12857
13355
  invalid_input: 400,
@@ -12862,6 +13360,8 @@ var init_routes = __esm(() => {
12862
13360
  auth: 401,
12863
13361
  network: 502,
12864
13362
  rate_limited: 429,
13363
+ busy: 503,
13364
+ clock_skew: 409,
12865
13365
  port_in_use: 500
12866
13366
  };
12867
13367
  syncRoutes = [
@@ -12873,12 +13373,18 @@ var init_routes = __esm(() => {
12873
13373
  response: SyncResponseSchema,
12874
13374
  async handler(req, { db, node }) {
12875
13375
  const body = await req.json();
12876
- ingest(db, body.changes ?? []);
13376
+ const r = ingestDetailed(db, body.changes ?? []);
13377
+ const skewed = Object.values(r.skewed).reduce((a, b) => a + b, 0);
12877
13378
  const batch = changesAfterSeq(db, body.since ?? 0, {
12878
13379
  limit: body.limit,
12879
13380
  excludeDatasets: body.exclude_datasets
12880
13381
  });
12881
- return Response.json({ node_id: node, changes: batch.changes, cursor: batch.cursor });
13382
+ return Response.json({
13383
+ node_id: node,
13384
+ changes: batch.changes,
13385
+ cursor: batch.cursor,
13386
+ ...skewed ? { skewed_rejected: skewed } : {}
13387
+ });
12882
13388
  }
12883
13389
  },
12884
13390
  {
@@ -13086,12 +13592,22 @@ function renewToken(req, url, cfg) {
13086
13592
  function wantsHtml(req) {
13087
13593
  return req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
13088
13594
  }
13089
- function unlockPage() {
13090
- return `<!doctype html><html lang="zh-CN"><head>
13595
+ function unlockPage(locale = "zh-CN") {
13596
+ const m = (key) => escapeAttr(pm(locale, key));
13597
+ const L = JSON.stringify({
13598
+ go: pm(locale, "\u89E3\u9501\u5E76\u8FDB\u5165"),
13599
+ show: pm(locale, "\u663E\u793A\u4EE4\u724C"),
13600
+ hide: pm(locale, "\u9690\u85CF\u4EE4\u724C"),
13601
+ empty: pm(locale, "\u8BF7\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C\u3002"),
13602
+ verifying: pm(locale, "\u9A8C\u8BC1\u4E2D\u2026"),
13603
+ invalid: pm(locale, "\u4EE4\u724C\u65E0\u6548,\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5\u3002"),
13604
+ expired: pm(locale, "\u767B\u5F55\u5DF2\u8FC7\u671F,\u8BF7\u91CD\u65B0\u8F93\u5165\u4EE4\u724C\u3002")
13605
+ });
13606
+ return `<!doctype html><html lang="${locale}"><head>
13091
13607
  <meta charset="utf-8">
13092
13608
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
13093
13609
  <meta name="theme-color" content="#ffffff">
13094
- <title>\u9A8C\u8BC1\u8BBF\u95EE\u4EE4\u724C \xB7 metahub</title>
13610
+ <title>${m("\u9A8C\u8BC1\u8BBF\u95EE\u4EE4\u724C \xB7 metahub")}</title>
13095
13611
  <script>
13096
13612
  // FOUC-guard theme resolution, same key/logic as src/webui/theme.ts.
13097
13613
  try {
@@ -13105,18 +13621,18 @@ function unlockPage() {
13105
13621
  <div class="boot" id="boot"><svg viewBox="0 0 24 24">${CUBE_SVG}</svg></div>
13106
13622
  <form class="card" id="f" style="display:none" autocomplete="on">
13107
13623
  <div class="brand"><svg viewBox="0 0 24 24">${CUBE_SVG}</svg></div>
13108
- <h1>\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C</h1>
13109
- <p class="sub">\u6B64\u670D\u52A1\u5668\u5DF2\u5F00\u542F\u8BBF\u95EE\u4FDD\u62A4\u3002\u5728\u5DF2\u767B\u5F55\u8BBE\u5907\u7684\u300C\u8BBE\u7F6E \u2192 \u8BBE\u5907\u4E0E\u6388\u6743\u300D\u83B7\u53D6\u767B\u5F55\u94FE\u63A5\u6216\u4E8C\u7EF4\u7801,\u6216\u5728\u670D\u52A1\u5668\u4E0A\u8FD0\u884C <code>mh token show</code>\u3002</p>
13624
+ <h1>${m("\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C")}</h1>
13625
+ <p class="sub">${pm(locale, "\u6B64\u670D\u52A1\u5668\u5DF2\u5F00\u542F\u8BBF\u95EE\u4FDD\u62A4\u3002\u5728\u5DF2\u767B\u5F55\u8BBE\u5907\u7684\u300C\u8BBE\u7F6E \u2192 \u8BBE\u5907\u4E0E\u6388\u6743\u300D\u83B7\u53D6\u767B\u5F55\u94FE\u63A5\u6216\u4E8C\u7EF4\u7801,\u6216\u5728\u670D\u52A1\u5668\u4E0A\u8FD0\u884C {cmd}\u3002", { cmd: "<code>mh token show</code>" })}</p>
13110
13626
  <div class="field">
13111
- <input id="t" type="password" placeholder="\u7C98\u8D34\u8BBF\u95EE\u4EE4\u724C\u6216\u767B\u5F55\u94FE\u63A5"
13627
+ <input id="t" type="password" placeholder="${m("\u7C98\u8D34\u8BBF\u95EE\u4EE4\u724C\u6216\u767B\u5F55\u94FE\u63A5")}"
13112
13628
  autocomplete="current-password" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
13113
- <button type="button" class="peek" id="peek" aria-label="\u663E\u793A\u4EE4\u724C"><svg viewBox="0 0 24 24">${EYE_SVG}</svg></button>
13629
+ <button type="button" class="peek" id="peek" aria-label="${m("\u663E\u793A\u4EE4\u724C")}"><svg viewBox="0 0 24 24">${EYE_SVG}</svg></button>
13114
13630
  </div>
13115
13631
  <p class="err" id="e" style="display:none"></p>
13116
- <button type="submit" class="go" id="go">\u89E3\u9501\u5E76\u8FDB\u5165</button>
13632
+ <button type="submit" class="go" id="go">${m("\u89E3\u9501\u5E76\u8FDB\u5165")}</button>
13117
13633
  </form>
13118
13634
  <script>
13119
- var KEY = "mh_token";
13635
+ var KEY = "mh_token", L = ${L};
13120
13636
  var form = document.getElementById("f"), boot = document.getElementById("boot");
13121
13637
  var input = document.getElementById("t"), errEl = document.getElementById("e");
13122
13638
  var goBtn = document.getElementById("go"), peek = document.getElementById("peek");
@@ -13141,7 +13657,7 @@ function unlockPage() {
13141
13657
  }
13142
13658
  function fail(msg) {
13143
13659
  goBtn.disabled = false;
13144
- goBtn.innerHTML = "\u89E3\u9501\u5E76\u8FDB\u5165";
13660
+ goBtn.textContent = L.go;
13145
13661
  errEl.textContent = msg;
13146
13662
  errEl.style.display = "flex";
13147
13663
  form.classList.remove("bad");
@@ -13154,7 +13670,7 @@ function unlockPage() {
13154
13670
  var pw = input.type === "password";
13155
13671
  input.type = pw ? "text" : "password";
13156
13672
  peek.innerHTML = '<svg viewBox="0 0 24 24">' + (pw ? ${JSON.stringify(EYE_OFF_SVG)} : ${JSON.stringify(EYE_SVG)}) + "</svg>";
13157
- peek.setAttribute("aria-label", pw ? "\u9690\u85CF\u4EE4\u724C" : "\u663E\u793A\u4EE4\u724C");
13673
+ peek.setAttribute("aria-label", pw ? L.hide : L.show);
13158
13674
  input.focus();
13159
13675
  });
13160
13676
  input.addEventListener("input", function () {
@@ -13164,9 +13680,9 @@ function unlockPage() {
13164
13680
  form.addEventListener("submit", function (ev) {
13165
13681
  ev.preventDefault();
13166
13682
  var token = normalize(input.value);
13167
- if (!token) { fail("\u8BF7\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C\u3002"); return; }
13683
+ if (!token) { fail(L.empty); return; }
13168
13684
  goBtn.disabled = true;
13169
- goBtn.innerHTML = '<span class="spin"></span>\u9A8C\u8BC1\u4E2D\u2026';
13685
+ goBtn.innerHTML = '<span class="spin"></span>' + L.verifying;
13170
13686
  var hdr = { headers: { authorization: "Bearer " + token } };
13171
13687
  // Prefer the renewal endpoint (managed mode returns the canonical token,
13172
13688
  // also upgrading a pasted in-grace token). Fall back to a cheap
@@ -13177,7 +13693,7 @@ function unlockPage() {
13177
13693
  .then(function (d) {
13178
13694
  if (d && d.token) { save(d.token); location.reload(); return; }
13179
13695
  return fetch("/api/version", hdr).then(function (r) {
13180
- if (r.status === 401) { fail("\u4EE4\u724C\u65E0\u6548,\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5\u3002"); return; }
13696
+ if (r.status === 401) { fail(L.invalid); return; }
13181
13697
  // 200 (static-token mode) or no UI (404) \u2192 trust it and reload.
13182
13698
  save(token); location.reload();
13183
13699
  });
@@ -13194,7 +13710,7 @@ function unlockPage() {
13194
13710
  .then(function (r) { return r.ok ? r.json() : null; })
13195
13711
  .then(function (d) {
13196
13712
  if (d && d.token) { save(d.token); location.reload(); }
13197
- else showForm("\u767B\u5F55\u5DF2\u8FC7\u671F,\u8BF7\u91CD\u65B0\u8F93\u5165\u4EE4\u724C\u3002");
13713
+ else showForm(L.expired);
13198
13714
  })
13199
13715
  .catch(function () { showForm(""); });
13200
13716
  } else {
@@ -13322,8 +13838,9 @@ var COOKIE = "mh_token", COOKIE_ATTRS = "path=/; SameSite=Strict; Max-Age=315360
13322
13838
  @keyframes spin{to{transform:rotate(360deg)}}
13323
13839
  @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important}
13324
13840
  .brand path{stroke-dashoffset:0}}
13325
- `;
13841
+ `, escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13326
13842
  var init_auth = __esm(() => {
13843
+ init_page_messages();
13327
13844
  init_inject_runtime();
13328
13845
  init_token();
13329
13846
  init_pairing();
@@ -13353,7 +13870,9 @@ var init_http_status = __esm(() => {
13353
13870
  stale: 409,
13354
13871
  conflict: 409,
13355
13872
  auth: 401,
13356
- rate_limited: 429
13873
+ rate_limited: 429,
13874
+ busy: 503,
13875
+ clock_skew: 409
13357
13876
  };
13358
13877
  });
13359
13878
 
@@ -14132,7 +14651,7 @@ async function serveSite(req, ctx, auth, opts = {}) {
14132
14651
  const ambientOnly = mutating && authActive(auth) && explicitToken(req, url) == null;
14133
14652
  if (!ambientOnly && hasValidToken(req, url, auth)) {
14134
14653
  if (!site)
14135
- return notFoundResponse("\u7AD9\u70B9\u4E0D\u5B58\u5728", `\u6CA1\u6709\u540D\u4E3A \u201C${escapeHtml(name)}\u201D \u7684\u7AD9\u70B9\u3002`);
14654
+ return siteNotFound(req, name);
14136
14655
  if (!opts.forwardApi)
14137
14656
  return new Response("not found", { status: 404 });
14138
14657
  const fwd = new Request(`${url.origin}/${filePath}${url.search}`, req);
@@ -14159,17 +14678,18 @@ async function serveSite(req, ctx, auth, opts = {}) {
14159
14678
  return serveSiteFile(req, ctx.db, site.id, filePath, { spa: site.spa === 1, isPublic: true });
14160
14679
  }
14161
14680
  if (!hasValidToken(req, url, auth)) {
14162
- return wantsHtml(req) ? new Response(unlockPage(), { headers: { ...HTML_HEADERS, "x-mh-unlock": "1" } }) : unauthorized();
14681
+ return wantsHtml(req) ? new Response(unlockPage(pickLocale(req)), { headers: { ...HTML_HEADERS, "x-mh-unlock": "1" } }) : unauthorized();
14163
14682
  }
14164
14683
  if (!site)
14165
- return notFoundResponse("\u7AD9\u70B9\u4E0D\u5B58\u5728", `\u6CA1\u6709\u540D\u4E3A \u201C${escapeHtml(name)}\u201D \u7684\u7AD9\u70B9\u3002`);
14684
+ return siteNotFound(req, name);
14166
14685
  const res = await serveSiteFile(req, ctx.db, site.id, filePath, { spa: site.spa === 1 });
14167
14686
  return withShim(res, auth, req, url);
14168
14687
  }
14169
14688
  async function serveSiteFile(req, db, siteId, path, opts = {}) {
14689
+ const locale = pickLocale(req);
14170
14690
  const meta = getFileMetaForServe(db, siteId, path, { spa: opts.spa });
14171
14691
  if (!meta)
14172
- return notFoundResponse("\u9875\u9762\u4E0D\u5B58\u5728", "\u8FD9\u4E2A\u7AD9\u70B9\u91CC\u6CA1\u6709\u8FD9\u4E2A\u9875\u9762\u6216\u6587\u4EF6\u3002");
14692
+ return notFoundResponse(locale, pm(locale, "\u9875\u9762\u4E0D\u5B58\u5728"), pm(locale, "\u8FD9\u4E2A\u7AD9\u70B9\u91CC\u6CA1\u6709\u8FD9\u4E2A\u9875\u9762\u6216\u6587\u4EF6\u3002"));
14173
14693
  const cacheControl = siteCacheControl(meta.row.content_type, meta.row.encoding, opts.isPublic ?? false);
14174
14694
  const headers = { etag: meta.etag, "cache-control": cacheControl };
14175
14695
  if (etagMatches(req.headers.get("if-none-match"), meta.etag)) {
@@ -14177,7 +14697,7 @@ async function serveSiteFile(req, db, siteId, path, opts = {}) {
14177
14697
  }
14178
14698
  const bytes = await decodeFileRow(db, meta.row);
14179
14699
  if (!bytes) {
14180
- return notFoundResponse("\u8D44\u6E90\u6682\u4E0D\u53EF\u7528", "\u6587\u4EF6\u7684\u5185\u5BB9\u6682\u65F6\u65E0\u6CD5\u53D6\u5F97\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002");
14700
+ return notFoundResponse(locale, pm(locale, "\u8D44\u6E90\u6682\u4E0D\u53EF\u7528"), pm(locale, "\u6587\u4EF6\u7684\u5185\u5BB9\u6682\u65F6\u65E0\u6CD5\u53D6\u5F97\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"));
14181
14701
  }
14182
14702
  const body = new Uint8Array(bytes.byteLength);
14183
14703
  body.set(bytes);
@@ -14202,8 +14722,12 @@ function etagMatches(header, etag) {
14202
14722
  }
14203
14723
  return false;
14204
14724
  }
14205
- function notFoundResponse(title, detail) {
14206
- const html = `<!doctype html><html lang="zh"><head>
14725
+ function siteNotFound(req, name) {
14726
+ const locale = pickLocale(req);
14727
+ return notFoundResponse(locale, pm(locale, "\u7AD9\u70B9\u4E0D\u5B58\u5728"), pm(locale, "\u6CA1\u6709\u540D\u4E3A \u201C{name}\u201D \u7684\u7AD9\u70B9\u3002", { name: escapeHtml(name) }));
14728
+ }
14729
+ function notFoundResponse(locale, title, detail) {
14730
+ const html = `<!doctype html><html lang="${locale}"><head>
14207
14731
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
14208
14732
  <meta name="robots" content="noindex">
14209
14733
  <title>${escapeHtml(title)}</title>
@@ -14224,8 +14748,8 @@ function notFoundResponse(title, detail) {
14224
14748
  <p class="code">404</p>
14225
14749
  <h1>${escapeHtml(title)}</h1>
14226
14750
  <p>${detail}</p>
14227
- <a class="home" href="./">\u8FD4\u56DE\u7AD9\u70B9\u9996\u9875</a>
14228
- <footer>\u7531 metahub \u6258\u7BA1</footer>
14751
+ <a class="home" href="./">${escapeHtml(pm(locale, "\u8FD4\u56DE\u7AD9\u70B9\u9996\u9875"))}</a>
14752
+ <footer>${escapeHtml(pm(locale, "\u7531 metahub \u6258\u7BA1"))}</footer>
14229
14753
  </main></body></html>`;
14230
14754
  return new Response(html, {
14231
14755
  status: 404,
@@ -14240,6 +14764,7 @@ var init_sites_serve = __esm(() => {
14240
14764
  init_grants_routes();
14241
14765
  init_rate_limit();
14242
14766
  init_share_render();
14767
+ init_page_messages();
14243
14768
  init_auth();
14244
14769
  });
14245
14770
 
@@ -14294,6 +14819,51 @@ async function mintGuestSession(scope, opts) {
14294
14819
  return { sub: opts.sub, exp, cookie };
14295
14820
  }
14296
14821
 
14822
+ // src/core/sync/share-copy.ts
14823
+ function markdownTable(header, rows) {
14824
+ const cell = (s) => s.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
14825
+ const line = (cells) => `| ${cells.map(cell).join(" | ")} |`;
14826
+ return [line(header), `| ${header.map(() => "---").join(" | ")} |`, ...rows.map(line)].join(`
14827
+ `);
14828
+ }
14829
+ function copyButtonHtml(locale = "zh-CN") {
14830
+ const label = escapeHtml(pm(locale, "\u590D\u5236\u5168\u6587"));
14831
+ return `<button id="mh-copy" class="copy-btn" type="button" aria-label="${label}">${ICON_COPY}${ICON_CHECK}<span>${label}</span></button>`;
14832
+ }
14833
+ function copySourceHtml(text) {
14834
+ return `<template id="mh-src">${escapeHtml(text)}</template>`;
14835
+ }
14836
+ function copyScript(locale = "zh-CN") {
14837
+ const L = JSON.stringify({ idle: pm(locale, "\u590D\u5236\u5168\u6587"), done: pm(locale, "\u5DF2\u590D\u5236"), fail: pm(locale, "\u590D\u5236\u5931\u8D25") });
14838
+ return `(function(){
14839
+ var btn=document.getElementById('mh-copy');if(!btn)return;var L=${L};
14840
+ var label=btn.querySelector('span');var timer=0;
14841
+ function absolutize(root){root.querySelectorAll('img[src],a[href],video[src],audio[src]').forEach(function(el){var a=el.tagName==='A'?'href':'src';var v=el.getAttribute(a);if(v&&!/^(blob|data):/.test(v))el.setAttribute(a,new URL(v,location.href).href);});}
14842
+ function payload(){var src=document.getElementById('mh-src');var text=src?src.content.textContent:'';var body=document.querySelector('article.doc')||document.querySelector('.table-wrap');var html='';if(body){var c=body.cloneNode(true);absolutize(c);html=c.innerHTML;}return{text:text,html:html};}
14843
+ function legacy(text){var ta=document.createElement('textarea');ta.value=text;ta.setAttribute('readonly','');ta.style.cssText='position:fixed;top:0;left:0;opacity:0';document.body.appendChild(ta);ta.select();var ok=false;try{ok=document.execCommand('copy');}catch(e){}ta.remove();return ok?Promise.resolve():Promise.reject(new Error('copy'));}
14844
+ function write(p){var nc=navigator.clipboard;if(nc&&nc.write&&window.ClipboardItem){try{return nc.write([new ClipboardItem({'text/plain':new Blob([p.text],{type:'text/plain'}),'text/html':new Blob([p.html],{type:'text/html'})})]);}catch(e){}}if(nc&&nc.writeText)return nc.writeText(p.text);return legacy(p.text);}
14845
+ function settle(cls,txt){clearTimeout(timer);btn.classList.remove('done','fail');void btn.offsetWidth;btn.classList.add(cls);if(label)label.textContent=txt;timer=setTimeout(function(){btn.classList.remove(cls);if(label)label.textContent=L.idle;},1600);}
14846
+ btn.addEventListener('click',function(){var p=payload();write(p).then(function(){settle('done',L.done);},function(){legacy(p.text).then(function(){settle('done',L.done);},function(){settle('fail',L.fail);});});});
14847
+ })();`;
14848
+ }
14849
+ var ICON_COPY = '<svg class="ic-copy" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>', ICON_CHECK = '<svg class="ic-check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></svg>', COPY_CSS = `.mh-tools{display:flex;align-items:center;gap:8px;flex:none}
14850
+ .copy-btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--fg);font:500 13px/1 inherit;font-family:inherit;cursor:pointer;transition:background .12s,border-color .12s,color .12s}
14851
+ .copy-btn:hover{background:var(--card)}.copy-btn:active{transform:translateY(.5px)}
14852
+ .copy-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
14853
+ .copy-btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
14854
+ .copy-btn .ic-check{display:none}
14855
+ .copy-btn.done{color:var(--ok,#1a7f37);border-color:var(--ok,#1a7f37)}
14856
+ .copy-btn.done .ic-copy{display:none}.copy-btn.done .ic-check{display:block}
14857
+ .copy-btn.done .ic-check path{stroke-dasharray:24;stroke-dashoffset:24;animation:mh-draw .22s ease-out forwards}
14858
+ .copy-btn.fail{color:#cf222e;border-color:#cf222e}
14859
+ @keyframes mh-draw{to{stroke-dashoffset:0}}
14860
+ @media (prefers-color-scheme:dark){:root{--ok:#3fb950}}
14861
+ @media (max-width:480px){.copy-btn span{display:none}.copy-btn{padding:0 9px}}
14862
+ @media (prefers-reduced-motion:reduce){.copy-btn .ic-check path{animation:none;stroke-dashoffset:0}}`;
14863
+ var init_share_copy = __esm(() => {
14864
+ init_page_messages();
14865
+ });
14866
+
14297
14867
  // src/core/sync/share-serve.ts
14298
14868
  function notFound() {
14299
14869
  return new Response("not found", { status: 404 });
@@ -14324,25 +14894,26 @@ async function serveShare(req, ctx, opts = {}) {
14324
14894
  return notFound();
14325
14895
  if (share.transport !== "server")
14326
14896
  return notFound();
14897
+ const locale = pickLocale(req);
14327
14898
  if (shareExpired(share))
14328
14899
  return gone();
14329
14900
  if (sub === "unlock" && req.method === "POST")
14330
14901
  return handleUnlock(ctx, req, share, url);
14331
14902
  const locked = !!share.pw_hash && !await readShareSession(ctx, req, share);
14332
14903
  if (locked) {
14333
- return wantsHtml2(req) ? new Response(passwordPage(share, false), { headers: HTML }) : unauthorizedJson();
14904
+ return wantsHtml2(req) ? new Response(passwordPage(locale, share, false), { headers: HTML }) : unauthorizedJson();
14334
14905
  }
14335
14906
  if (sub.startsWith("blob/"))
14336
14907
  return serveScopedBlob(ctx, share, sub.slice("blob/".length));
14337
14908
  if (share.kind === "doc")
14338
- return serveDoc(ctx, req, share, sub);
14909
+ return serveDoc(ctx, req, share, sub, locale);
14339
14910
  if (share.kind === "database")
14340
- return serveTable(ctx, req, share, sub);
14911
+ return serveTable(ctx, req, share, sub, locale);
14341
14912
  if (share.kind === "site")
14342
- return serveSiteShare(ctx, req, share, sub, opts);
14913
+ return serveSiteShare(ctx, req, share, sub, opts, locale);
14343
14914
  return notFound();
14344
14915
  }
14345
- async function serveDoc(ctx, req, share, sub) {
14916
+ async function serveDoc(ctx, req, share, sub, locale) {
14346
14917
  const doc = getDocument(ctx.db, share.target_id);
14347
14918
  if (!doc)
14348
14919
  return notFound();
@@ -14371,26 +14942,27 @@ async function serveDoc(ctx, req, share, sub) {
14371
14942
  });
14372
14943
  const version = documentVersion(ctx.db, share.target_id);
14373
14944
  const editable = share.permission === "edit";
14374
- const inner = `<article class="doc">${rendered || '<p class="muted">\uFF08\u7A7A\u6587\u6863\uFF09</p>'}</article>`;
14375
- const script = editable ? docEditScript(share.slug, version, doc.body ?? "") : "";
14376
- return new Response(pageShell(doc.title || "\u6587\u6863", inner, { editable, editLabel: "\u7F16\u8F91\u6587\u6863", script }), { headers: HTML });
14945
+ const inner = `<article class="doc">${rendered || `<p class="muted">${escapeHtml(pm(locale, "\uFF08\u7A7A\u6587\u6863\uFF09"))}</p>`}</article>`;
14946
+ const script = editable ? docEditScript(locale, share.slug, version, doc.body ?? "") : "";
14947
+ return new Response(pageShell(doc.title || pm(locale, "\u6587\u6863"), inner, { lang: locale, editable, editLabel: pm(locale, "\u7F16\u8F91\u6587\u6863"), script, copySource: doc.body ?? "" }), { headers: HTML });
14377
14948
  }
14378
- function docEditScript(slug, version, body) {
14949
+ function docEditScript(locale, slug, version, body) {
14950
+ const L = JSON.stringify({ save: pm(locale, "\u4FDD\u5B58"), saving: pm(locale, "\u4FDD\u5B58\u4E2D\u2026"), failed: pm(locale, "\u4FDD\u5B58\u5931\u8D25") });
14379
14951
  return `
14380
- let editing=false; const ver=${JSON.stringify(version)}; const body0=${JSON.stringify(body)};
14952
+ let editing=false; const ver=${JSON.stringify(version)}; const body0=${JSON.stringify(body)}; const L=${L};
14381
14953
  const art=document.querySelector('article.doc');
14382
14954
  const btn=document.getElementById('mh-edit');
14383
14955
  let ta;
14384
14956
  btn.addEventListener('click', async ()=>{
14385
14957
  if(!editing){
14386
- editing=true; btn.textContent='\u4FDD\u5B58';
14958
+ editing=true; btn.textContent=L.save;
14387
14959
  ta=document.createElement('textarea'); ta.className='mh-raw'; ta.value=body0;
14388
14960
  art.replaceWith(ta); ta.focus();
14389
14961
  } else {
14390
- btn.disabled=true; btn.textContent='\u4FDD\u5B58\u4E2D\u2026';
14962
+ btn.disabled=true; btn.textContent=L.saving;
14391
14963
  const res=await fetch('/share/${slug}/doc',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({body:ta.value,ifMatch:ver})});
14392
14964
  if(res.ok){ location.reload(); }
14393
- else { const j=await res.json().catch(()=>({})); alert(j.error||'\u4FDD\u5B58\u5931\u8D25'); btn.disabled=false; btn.textContent='\u4FDD\u5B58'; }
14965
+ else { const j=await res.json().catch(()=>({})); alert(j.error||L.failed); btn.disabled=false; btn.textContent=L.save; }
14394
14966
  }
14395
14967
  });`;
14396
14968
  }
@@ -14419,7 +14991,7 @@ function cellHtml(prop, value, titles) {
14419
14991
  function editableType(t) {
14420
14992
  return t === "text" || t === "number" || t === "url" || t === "date";
14421
14993
  }
14422
- async function serveTable(ctx, req, share, sub) {
14994
+ async function serveTable(ctx, req, share, sub, locale) {
14423
14995
  const dbRow = getDatabase(ctx.db, share.target_id);
14424
14996
  if (!dbRow)
14425
14997
  return notFound();
@@ -14470,13 +15042,18 @@ async function serveTable(ctx, req, share, sub) {
14470
15042
  return `<tr>${tds}</tr>`;
14471
15043
  }).join("");
14472
15044
  const inner = `<div class="table-wrap"><table class="db"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>`;
14473
- const script = editable ? tableEditScript(share.slug) : "";
14474
- return new Response(pageShell(dbRow.name || "\u8868\u683C", inner, { script, hint: editable ? "\u53EF\u7F16\u8F91\u6587\u672C/\u6570\u5B57/URL/\u65E5\u671F\u5355\u5143\u683C\uFF0C\u5931\u7126\u81EA\u52A8\u4FDD\u5B58" : "" }), {
14475
- headers: HTML
14476
- });
14477
- }
14478
- function tableEditScript(slug) {
15045
+ const script = editable ? tableEditScript(locale, share.slug) : "";
15046
+ const copySource = markdownTable(props.map((p) => p.name), records.map((r) => props.map((p) => cellText(p, r.cells[p.id], titlesFor(p)))));
15047
+ return new Response(pageShell(dbRow.name || pm(locale, "\u8868\u683C"), inner, {
15048
+ lang: locale,
15049
+ script,
15050
+ copySource,
15051
+ hint: editable ? pm(locale, "\u53EF\u7F16\u8F91\u6587\u672C/\u6570\u5B57/URL/\u65E5\u671F\u5355\u5143\u683C\uFF0C\u5931\u7126\u81EA\u52A8\u4FDD\u5B58") : ""
15052
+ }), { headers: HTML });
15053
+ }
15054
+ function tableEditScript(locale, slug) {
14479
15055
  return `
15056
+ const L=${JSON.stringify({ failed: pm(locale, "\u4FDD\u5B58\u5931\u8D25") })};
14480
15057
  document.querySelectorAll('td[contenteditable]').forEach(td=>{
14481
15058
  let orig=td.textContent;
14482
15059
  td.addEventListener('blur', async ()=>{
@@ -14488,11 +15065,11 @@ function tableEditScript(slug) {
14488
15065
  const res=await fetch('/share/${slug}/record',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:td.dataset.rec,propId:td.dataset.prop,value:v})});
14489
15066
  td.classList.remove('saving');
14490
15067
  if(res.ok){ orig=val; td.classList.add('saved'); setTimeout(()=>td.classList.remove('saved'),600); }
14491
- else { td.textContent=orig; const j=await res.json().catch(()=>({})); alert(j.error||'\u4FDD\u5B58\u5931\u8D25'); }
15068
+ else { td.textContent=orig; const j=await res.json().catch(()=>({})); alert(j.error||L.failed); }
14492
15069
  });
14493
15070
  });`;
14494
15071
  }
14495
- async function serveSiteShare(ctx, req, share, sub, opts = {}) {
15072
+ async function serveSiteShare(ctx, req, share, sub, opts = {}, locale = "zh-CN") {
14496
15073
  let site;
14497
15074
  try {
14498
15075
  site = resolveSite(ctx.db, share.target_id);
@@ -14515,7 +15092,7 @@ async function serveSiteShare(ctx, req, share, sub, opts = {}) {
14515
15092
  }
14516
15093
  if (share.permission === "edit") {
14517
15094
  if (sub === "_files")
14518
- return new Response(siteFilesPage(ctx, share, siteId), { headers: HTML });
15095
+ return new Response(siteFilesPage(ctx, share, siteId, locale), { headers: HTML });
14519
15096
  if (sub === "_file" && req.method === "POST") {
14520
15097
  const body = await req.json().catch(() => ({}));
14521
15098
  if (!body.path || typeof body.content !== "string")
@@ -14547,34 +15124,35 @@ async function serveSiteShare(ctx, req, share, sub, opts = {}) {
14547
15124
  }
14548
15125
  return serveSiteFile(req, ctx.db, siteId, sub, { spa: site.spa === 1 });
14549
15126
  }
14550
- function siteFilesPage(ctx, share, siteId) {
15127
+ function siteFilesPage(ctx, share, siteId, locale) {
14551
15128
  const files = listFiles(ctx.db, siteId);
15129
+ const L = JSON.stringify({ binary: pm(locale, "\uFF08\u4E8C\u8FDB\u5236\u6587\u4EF6\uFF0C\u4E0D\u53EF\u5728\u7EBF\u7F16\u8F91\uFF09"), saved: pm(locale, "\u5DF2\u4FDD\u5B58 \u2713"), save: pm(locale, "\u4FDD\u5B58"), failed: pm(locale, "\u4FDD\u5B58\u5931\u8D25") });
14552
15130
  const list = files.map((f) => `<li><button class="file" data-path="${escapeHtml(f.path)}" data-text="${f.encoding === "utf8" ? "1" : "0"}">${escapeHtml(f.path)}</button> <span class="muted">${escapeHtml(f.content_type)}</span></li>`).join("");
14553
15131
  const inner = `
14554
- <p class="hint">\u7F16\u8F91\u7AD9\u70B9\u6587\u4EF6\uFF08\u6587\u672C\u6587\u4EF6\u53EF\u5728\u7EBF\u7F16\u8F91\uFF1B\u4FDD\u5B58\u5373\u5BF9\u6240\u6709\u8BBF\u95EE\u8005\u751F\u6548\uFF09\u3002</p>
14555
- <ul class="files">${list || '<li class="muted">\uFF08\u6682\u65E0\u6587\u4EF6\uFF09</li>'}</ul>
15132
+ <p class="hint">${escapeHtml(pm(locale, "\u7F16\u8F91\u7AD9\u70B9\u6587\u4EF6\uFF08\u6587\u672C\u6587\u4EF6\u53EF\u5728\u7EBF\u7F16\u8F91\uFF1B\u4FDD\u5B58\u5373\u5BF9\u6240\u6709\u8BBF\u95EE\u8005\u751F\u6548\uFF09\u3002"))}</p>
15133
+ <ul class="files">${list || `<li class="muted">${escapeHtml(pm(locale, "\uFF08\u6682\u65E0\u6587\u4EF6\uFF09"))}</li>`}</ul>
14556
15134
  <div id="editor" hidden>
14557
- <div class="row"><strong id="fpath"></strong> <a id="open" target="_blank" rel="noreferrer">\u9884\u89C8 \u2197</a></div>
15135
+ <div class="row"><strong id="fpath"></strong> <a id="open" target="_blank" rel="noreferrer">${escapeHtml(pm(locale, "\u9884\u89C8 \u2197"))}</a></div>
14558
15136
  <textarea class="mh-raw" id="fbody"></textarea>
14559
- <div class="row"><button id="save">\u4FDD\u5B58</button></div>
15137
+ <div class="row"><button id="save">${escapeHtml(pm(locale, "\u4FDD\u5B58"))}</button></div>
14560
15138
  </div>`;
14561
15139
  const script = `
14562
- const slug=${JSON.stringify(share.slug)};
15140
+ const slug=${JSON.stringify(share.slug)}; const L=${L};
14563
15141
  const ed=document.getElementById('editor'), fpath=document.getElementById('fpath'), fbody=document.getElementById('fbody'), open=document.getElementById('open');
14564
15142
  let cur=null;
14565
15143
  document.querySelectorAll('button.file').forEach(b=>b.addEventListener('click', async ()=>{
14566
15144
  cur=b.dataset.path; fpath.textContent=cur; open.href='/share/'+slug+'/'+cur;
14567
15145
  if(b.dataset.text==='1'){ const r=await fetch('/share/'+slug+'/_raw?path='+encodeURIComponent(cur)); const j=await r.json(); fbody.value=j.content||''; fbody.disabled=false; }
14568
- else { fbody.value='\uFF08\u4E8C\u8FDB\u5236\u6587\u4EF6\uFF0C\u4E0D\u53EF\u5728\u7EBF\u7F16\u8F91\uFF09'; fbody.disabled=true; }
15146
+ else { fbody.value=L.binary; fbody.disabled=true; }
14569
15147
  ed.hidden=false;
14570
15148
  }));
14571
15149
  document.getElementById('save').addEventListener('click', async ()=>{
14572
15150
  if(!cur||fbody.disabled) return;
14573
15151
  const res=await fetch('/share/'+slug+'/_file',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:cur,content:fbody.value})});
14574
- if(res.ok){ const s=document.getElementById('save'); s.textContent='\u5DF2\u4FDD\u5B58 \u2713'; setTimeout(()=>s.textContent='\u4FDD\u5B58',900); }
14575
- else { const j=await res.json().catch(()=>({})); alert(j.error||'\u4FDD\u5B58\u5931\u8D25'); }
15152
+ if(res.ok){ const s=document.getElementById('save'); s.textContent=L.saved; setTimeout(()=>s.textContent=L.save,900); }
15153
+ else { const j=await res.json().catch(()=>({})); alert(j.error||L.failed); }
14576
15154
  });`;
14577
- return pageShell(`${resolveSite(ctx.db, share.target_id).name} \xB7 \u6587\u4EF6`, inner, { script });
15155
+ return pageShell(pm(locale, "{name} \xB7 \u6587\u4EF6", { name: resolveSite(ctx.db, share.target_id).name }), inner, { lang: locale, script });
14578
15156
  }
14579
15157
  function referencedHashes2(ctx, share) {
14580
15158
  const out = new Set;
@@ -14662,7 +15240,7 @@ async function handleUnlock(ctx, req, share, url) {
14662
15240
  pw = form ? String(form.get("password") ?? "") : "";
14663
15241
  }
14664
15242
  if (!await verifySharePassword(share, pw)) {
14665
- return new Response(passwordPage(share, true), { headers: HTML, status: 401 });
15243
+ return new Response(passwordPage(pickLocale(req), share, true), { headers: HTML, status: 401 });
14666
15244
  }
14667
15245
  const { cookie } = await mintShareSession(ctx, share, url);
14668
15246
  return new Response(null, {
@@ -14673,21 +15251,23 @@ async function handleUnlock(ctx, req, share, url) {
14673
15251
  function ttlRemaining(share) {
14674
15252
  return share.expires_at == null ? SHARE_SESSION_TTL_MS : Math.max(0, share.expires_at - Date.now());
14675
15253
  }
14676
- function passwordPage(share, error) {
15254
+ function passwordPage(locale, share, error) {
14677
15255
  const inner = `
14678
15256
  <form method="post" action="/share/${share.slug}/unlock" class="pw">
14679
- <h1>\uD83D\uDD12 \u6B64\u5206\u4EAB\u53D7\u53E3\u4EE4\u4FDD\u62A4</h1>
14680
- ${error ? '<p class="err">\u53E3\u4EE4\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002</p>' : ""}
14681
- <input type="password" name="password" placeholder="\u53E3\u4EE4" autofocus autocomplete="current-password">
14682
- <button type="submit">\u89E3\u9501</button>
15257
+ <h1>${escapeHtml(pm(locale, "\uD83D\uDD12 \u6B64\u5206\u4EAB\u53D7\u53E3\u4EE4\u4FDD\u62A4"))}</h1>
15258
+ ${error ? `<p class="err">${escapeHtml(pm(locale, "\u53E3\u4EE4\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002"))}</p>` : ""}
15259
+ <input type="password" name="password" placeholder="${escapeHtml(pm(locale, "\u53E3\u4EE4"))}" autofocus autocomplete="current-password">
15260
+ <button type="submit">${escapeHtml(pm(locale, "\u89E3\u9501"))}</button>
14683
15261
  </form>`;
14684
- return pageShell("\u53D7\u4FDD\u62A4\u7684\u5206\u4EAB", inner, { bare: true });
15262
+ return pageShell(pm(locale, "\u53D7\u4FDD\u62A4\u7684\u5206\u4EAB"), inner, { lang: locale, bare: true });
14685
15263
  }
14686
- function pageShell(title, inner, opts = {}) {
14687
- const editBtn = opts.editable ? `<button id="mh-edit" class="edit-btn">${opts.editLabel ?? "\u7F16\u8F91"}</button>` : "";
15264
+ function pageShell(title, inner, opts) {
15265
+ const editBtn = opts.editable ? `<button id="mh-edit" class="edit-btn">${escapeHtml(opts.editLabel ?? pm(opts.lang, "\u7F16\u8F91"))}</button>` : "";
15266
+ const copyBtn = opts.copySource !== undefined ? copyButtonHtml(opts.lang) : "";
15267
+ const tools = copyBtn || editBtn ? `<div class="mh-tools">${copyBtn}${editBtn}</div>` : "";
14688
15268
  const hint = opts.hint ? `<p class="hint">${escapeHtml(opts.hint)}</p>` : "";
14689
- const script = opts.script ? `<script>(function(){${opts.script}})();</script>` : "";
14690
- return `<!doctype html><html lang="zh"><head>
15269
+ const script = (opts.script ? `<script>(function(){${opts.script}})();</script>` : "") + (opts.copySource !== undefined ? `${copySourceHtml(opts.copySource)}<script>${copyScript(opts.lang)}</script>` : "");
15270
+ return `<!doctype html><html lang="${opts.lang}"><head>
14691
15271
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
14692
15272
  <meta name="robots" content="noindex">
14693
15273
  <title>${escapeHtml(title)}</title>
@@ -14699,7 +15279,8 @@ function pageShell(title, inner, opts = {}) {
14699
15279
  .wrap{max-width:820px;margin:0 auto;padding:32px 20px 80px}
14700
15280
  header.mh{display:flex;align-items:center;gap:12px;justify-content:space-between;margin-bottom:20px;border-bottom:1px solid var(--line);padding-bottom:14px}
14701
15281
  header.mh h1.title{font-size:22px;margin:0}
14702
- .edit-btn{background:var(--accent);color:#fff;border:0;border-radius:7px;padding:7px 14px;cursor:pointer;font-size:14px}
15282
+ .edit-btn{background:var(--accent);color:#fff;border:0;border-radius:7px;height:32px;padding:0 14px;cursor:pointer;font-size:13px;font-weight:500;font-family:inherit}
15283
+ ${COPY_CSS}
14703
15284
  .hint{color:var(--muted);font-size:13px;margin:0 0 14px}
14704
15285
  article.doc h1,article.doc h2,article.doc h3{line-height:1.3;margin:1.4em 0 .5em}
14705
15286
  article.doc h1{font-size:1.7em} article.doc h2{font-size:1.4em} article.doc h3{font-size:1.2em}
@@ -14730,10 +15311,10 @@ function pageShell(title, inner, opts = {}) {
14730
15311
  footer.mh{margin-top:48px;border-top:1px solid var(--line);padding-top:14px;color:var(--muted);font-size:12px;text-align:center}
14731
15312
  </style></head><body>
14732
15313
  ${opts.bare ? `<div class="wrap">${inner}</div>` : `<div class="wrap">
14733
- <header class="mh"><h1 class="title">${escapeHtml(title)}</h1>${editBtn}</header>
15314
+ <header class="mh"><h1 class="title">${escapeHtml(title)}</h1>${tools}</header>
14734
15315
  ${hint}
14735
15316
  ${inner}
14736
- <footer class="mh">\u901A\u8FC7 metahub \u5206\u4EAB</footer>
15317
+ <footer class="mh">${escapeHtml(pm(opts.lang, "\u901A\u8FC7 metahub \u5206\u4EAB"))}</footer>
14737
15318
  </div>`}
14738
15319
  ${script}
14739
15320
  </body></html>`;
@@ -14756,6 +15337,8 @@ var init_share_serve = __esm(() => {
14756
15337
  init_grants_routes();
14757
15338
  init_rate_limit();
14758
15339
  init_share_render();
15340
+ init_share_copy();
15341
+ init_page_messages();
14759
15342
  HTML = { "content-type": "text/html; charset=utf-8" };
14760
15343
  HASH_RE2 = /^[0-9a-f]{16,64}$/;
14761
15344
  SHARE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
@@ -15132,11 +15715,12 @@ var DROP_ENVELOPE_MAX_BYTES, DROP_HLC_SKEW_MS, GUEST_NODE_RE;
15132
15715
  var init_drop_protocol = __esm(() => {
15133
15716
  init_errors();
15134
15717
  init_ids();
15718
+ init_hlc();
15135
15719
  init_grants_core();
15136
15720
  init_e2ee();
15137
15721
  init_seal();
15138
15722
  DROP_ENVELOPE_MAX_BYTES = 64 * 1024;
15139
- DROP_HLC_SKEW_MS = 5 * 60000;
15723
+ DROP_HLC_SKEW_MS = HLC_MAX_SKEW_MS;
15140
15724
  GUEST_NODE_RE = /^g[0-9a-z]{8}$/;
15141
15725
  });
15142
15726
 
@@ -15190,12 +15774,12 @@ function applyDropV2(db, set, payload, now) {
15190
15774
  };
15191
15775
  const before = db.query("SELECT MAX(seq) AS s FROM crdt_changes").get().s ?? 0;
15192
15776
  const ids = [];
15193
- db.transaction(() => {
15777
+ writeTx(db, () => {
15194
15778
  for (const intent of payload.intents) {
15195
15779
  applyGuestIntent(db, policy, { guestNode: payload.guest_node }, intent, { clock: "submitted", now });
15196
15780
  ids.push(intent.intentId);
15197
15781
  }
15198
- })();
15782
+ });
15199
15783
  const ingested = db.query("SELECT COUNT(*) AS n FROM crdt_changes WHERE seq > ?").get(before).n;
15200
15784
  return { ackSeq: intentsMaxSeq(db, payload.guest_node, ids), ingested };
15201
15785
  }
@@ -15373,24 +15957,48 @@ init_paths();
15373
15957
  // src/core/db.ts
15374
15958
  init_paths();
15375
15959
  init_schema_init();
15960
+ init_node();
15376
15961
  init_schema_init();
15377
15962
  import { Database } from "bun:sqlite";
15378
15963
  import { mkdirSync } from "fs";
15964
+ import { hostname } from "os";
15379
15965
  function ensureDirs() {
15380
15966
  mkdirSync(metahubHome(), { recursive: true });
15381
15967
  mkdirSync(cacheDir(), { recursive: true });
15382
15968
  }
15969
+ function appKind() {
15970
+ const v = process.env.METAHUB_APP;
15971
+ return v === "server" || v === "desktop" || v === "web" ? v : "cli";
15972
+ }
15383
15973
  function openMetahub() {
15384
15974
  ensureDirs();
15385
15975
  const db = new Database(dbPath(), { create: true });
15976
+ db.exec("PRAGMA busy_timeout = 5000;");
15386
15977
  db.exec("PRAGMA journal_mode = WAL;");
15387
15978
  initSchema(db);
15979
+ const app = appKind();
15980
+ const platform = platformFromProcess(process.platform);
15981
+ describeSelf(db, {
15982
+ app,
15983
+ platform,
15984
+ form: defaultForm(platform, app),
15985
+ defaultLabel: safeHostname()
15986
+ });
15388
15987
  return db;
15389
15988
  }
15989
+ function safeHostname() {
15990
+ try {
15991
+ const h = hostname();
15992
+ return h ? h.replace(/\.local$/i, "") : null;
15993
+ } catch {
15994
+ return null;
15995
+ }
15996
+ }
15390
15997
 
15391
15998
  // src/core/index.ts
15392
15999
  init_ids();
15393
16000
  init_resolve();
16001
+ init_hlc();
15394
16002
  init_crdt();
15395
16003
  init_node();
15396
16004
  init_databases();
@@ -15517,18 +16125,18 @@ function ensureIndex(db) {
15517
16125
  return false;
15518
16126
  const version = readMeta(db, "search_index_version");
15519
16127
  const seq = readMeta(db, "search_seq");
15520
- db.transaction(() => {
16128
+ writeTx(db, () => {
15521
16129
  if (version !== SEARCH_INDEX_VERSION || seq === null)
15522
16130
  fullRebuild(db);
15523
16131
  else
15524
16132
  incrementalUpdate(db);
15525
- })();
16133
+ });
15526
16134
  return true;
15527
16135
  }
15528
16136
  function rebuildSearchIndex(db) {
15529
16137
  if (!ftsAvailable(db))
15530
16138
  return false;
15531
- db.transaction(() => fullRebuild(db))();
16139
+ writeTx(db, () => fullRebuild(db));
15532
16140
  return true;
15533
16141
  }
15534
16142
  function ftsSearch(db, query, limit) {
@@ -15677,6 +16285,7 @@ function scalarHtml(specUrl) {
15677
16285
  }
15678
16286
  function startServer(opts = {}) {
15679
16287
  registerRoomBlobResolver(resolveBlob);
16288
+ process.env.METAHUB_APP ??= "server";
15680
16289
  const tDb = Date.now();
15681
16290
  const db = openMetahub();
15682
16291
  const dbMs = Date.now() - tDb;
@@ -15687,6 +16296,7 @@ function startServer(opts = {}) {
15687
16296
  allowRemoteSiteHosting: opts.allowRemoteSiteHosting ?? true
15688
16297
  };
15689
16298
  const allRoutes = opts.ui ? [...routes, ...opts.ui.routes] : routes;
16299
+ opts.ui?.onStart?.(db);
15690
16300
  const forwardApi = async (req) => {
15691
16301
  const path = new URL(req.url).pathname;
15692
16302
  const route = allRoutes.find((r) => r.method === req.method && r.path === path);
@@ -15732,7 +16342,7 @@ function startServer(opts = {}) {
15732
16342
  } else {
15733
16343
  const exempt = url.pathname === HEALTH_PATH || url.pathname === RENEW_PATH || url.pathname === PAIR_PATH || url.pathname === "/manifest.webmanifest" || url.pathname === "/sw.js" || url.pathname === "/metahub-sdk.js" || url.pathname.startsWith("/icons/") || url.pathname.startsWith("/share/") || url.pathname.startsWith("/sites/");
15734
16344
  if (!exempt && !hasValidToken(req, url, auth)) {
15735
- return wantsHtml(req) ? new Response(unlockPage(), { headers: { ...HTML_HEADERS, "x-mh-unlock": "1" } }) : unauthorized();
16345
+ return wantsHtml(req) ? new Response(unlockPage(pickLocale(req)), { headers: { ...HTML_HEADERS, "x-mh-unlock": "1" } }) : unauthorized();
15736
16346
  }
15737
16347
  }
15738
16348
  if (url.pathname === RENEW_PATH) {
@@ -15899,6 +16509,8 @@ var styles_default = ` :root {
15899
16509
  --fs-body:14px; --fs-ui:13.5px; --fs-sm:12.5px; --fs-xs:11.5px; --fs-micro:11px;
15900
16510
  --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
15901
16511
  --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
16512
+ --kbd-font:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
16513
+ --tip-bg:#1f1f23; --tip-fg:#ffffff; --tip-kbd-bg:rgba(255,255,255,.14); --tip-kbd-fg:rgba(255,255,255,.92);
15902
16514
  --note-lh:1.6;
15903
16515
  --code-bg:#f8f8f6;
15904
16516
  --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
@@ -15917,6 +16529,7 @@ var styles_default = ` :root {
15917
16529
  --hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
15918
16530
  --shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
15919
16531
  --code-bg:#1d1d20;
16532
+ --tip-bg:#2e2e32; --tip-fg:var(--fg); --tip-kbd-bg:rgba(255,255,255,.08); --tip-kbd-fg:var(--fg-soft);
15920
16533
  --hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
15921
16534
  --hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
15922
16535
  }
@@ -15995,12 +16608,11 @@ var styles_default = ` :root {
15995
16608
  background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
15996
16609
  display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
15997
16610
  .brand .mark svg { width:15px; height:15px; stroke:#fff; }
15998
- .sb-search { margin:0 12px 8px; display:flex; align-items:center; gap:7px; cursor:text;
16611
+ .sb-search { margin:0 12px 8px; width:calc(100% - 24px); display:flex; align-items:center; gap:7px; cursor:pointer; text-align:left;
15999
16612
  background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
16000
- box-shadow:var(--shadow-sm); transition:border-color .15s; }
16001
- .sb-search:focus-within { border-color:var(--accent); }
16002
- .sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
16003
- .sb-search kbd { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
16613
+ box-shadow:var(--shadow-sm); transition:border-color .15s; font:inherit; }
16614
+ .sb-search:hover, .sb-search:focus-visible { border-color:var(--line-strong); outline:none; }
16615
+ .sb-search-ph { flex:1; min-width:0; color:var(--muted); font-size:var(--fs-ui); }
16004
16616
  .sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
16005
16617
  /* Notion-style list switcher: the active tab is an icon+label pill, inactive
16006
16618
  tabs are icon-only. One absolutely-positioned indicator slides & stretches
@@ -16171,9 +16783,34 @@ var styles_default = ` :root {
16171
16783
  .set-row-line { display:flex; align-items:center; gap:14px; min-height:44px; padding:5px 0; }
16172
16784
  .set-row.clickable > .set-row-line { cursor:pointer; }
16173
16785
  .set-row-text { flex:1 1 auto; min-width:0; }
16174
- .set-row-title { font-size:14px; font-weight:550; color:var(--fg); }
16175
- .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px;
16786
+ .set-row-title { font-size:14px; font-weight:550; color:var(--fg); display:flex; align-items:center; gap:8px; min-width:0; }
16787
+ .set-row-title > .set-row-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16788
+ .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px; display:flex; align-items:center; gap:6px;
16176
16789
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
16790
+ /* leading glyph (device form / bucket) \u2014 a soft tile so rows scan by shape */
16791
+ .set-row-lead { flex:none; width:28px; height:28px; display:grid; place-items:center; color:var(--muted);
16792
+ border-radius:7px; background:var(--surface-2); }
16793
+ .set-row-lead .ico { width:15px; height:15px; }
16794
+ .set-row.dim > .set-row-line { opacity:.62; }
16795
+ /* status dot in a caption: the only colour a row carries */
16796
+ .cap-dot { display:inline-block; flex:none; width:7px; height:7px; border-radius:50%; background:var(--muted); }
16797
+ .cap-dot.ok { background:var(--success); } .cap-dot.warn { background:var(--warning,#9a6700); } .cap-dot.err { background:var(--danger); }
16798
+ .cap-err { color:var(--danger); }
16799
+ /* tag variants next to a title: neutral outline (default), accent (roles), self (soft fill) */
16800
+ .set-row-title .set-row-tag { margin-left:0; vertical-align:0; flex:none; }
16801
+ .set-row-tag.accent { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
16802
+ .set-row-tag.self { color:var(--fg-soft); background:var(--surface-2); border-color:transparent; }
16803
+ .set-row-tag.mono { font-family:var(--mono); letter-spacing:-.01em; }
16804
+ /* quiet "+ add" row closing a list, and a "N more\u2026" fold */
16805
+ .add-row { display:flex; align-items:center; gap:8px; width:100%; min-height:40px; margin-top:2px; padding:0 2px;
16806
+ color:var(--muted); font-size:var(--fs-ui); border-radius:var(--radius-sm); text-align:left; }
16807
+ .add-row:hover { color:var(--fg); background:var(--hover); }
16808
+ .add-row .ico { width:15px; height:15px; }
16809
+ .more-row { display:inline-flex; align-items:center; gap:6px; padding:8px 2px; font-size:var(--fs-sm); color:var(--muted); }
16810
+ .more-row:hover { color:var(--fg); }
16811
+ .set-section-head { display:flex; align-items:baseline; gap:10px; margin:0 0 4px 2px; }
16812
+ .set-section-head .set-section-label { margin:0; }
16813
+ .set-section-count { font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; }
16177
16814
  .set-row-control { flex:none; display:flex; align-items:center; gap:8px; }
16178
16815
  .set-row-detail { padding:2px 0 12px; }
16179
16816
  .set-row.disabled > .set-row-line { opacity:.55; pointer-events:none; }
@@ -16189,6 +16826,9 @@ var styles_default = ` :root {
16189
16826
  .set-row-tag { display:inline-block; margin-left:8px; font-size:11px; font-weight:500;
16190
16827
  color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; vertical-align:1px; }
16191
16828
  .set-row-input { width:15em; min-width:0; }
16829
+ /* row dropdown (primitives.tsx RowSelect): current value + chevron */
16830
+ .row-select { gap:4px; padding:4px 6px 4px 10px; }
16831
+ .row-select .ico { width:13px; height:13px; color:var(--muted); }
16192
16832
 
16193
16833
  /* Page chrome (primitives.tsx PageHeader / DangerZone) \u2014 every page opens
16194
16834
  with its own PageHeader (the shell renders no title). */
@@ -16197,7 +16837,7 @@ var styles_default = ` :root {
16197
16837
  .page-header-text { flex:1 1 auto; min-width:0; }
16198
16838
  .page-header-title { font-size:26px; font-weight:700; letter-spacing:-.02em; color:var(--fg); margin:0; }
16199
16839
  .page-header-sub { color:var(--muted); font-size:var(--fs-sm); margin-top:3px; }
16200
- .page-header-action { flex:none; padding-top:4px; }
16840
+ .page-header-action { flex:none; display:flex; align-items:center; gap:8px; padding-top:4px; }
16201
16841
  .page-header-banner { margin-top:8px; }
16202
16842
  .danger-zone { margin-top:34px; }
16203
16843
  .danger-zone-label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em;
@@ -16238,15 +16878,14 @@ var styles_default = ` :root {
16238
16878
  .audit-detail { padding:0 4px 10px 86px; }
16239
16879
  .audit-detail-ent-name { font-size:var(--fs-sm); font-weight:600; margin-top:6px; }
16240
16880
  .audit-blocks { font-size:var(--fs-sm); margin-top:2px; }
16241
- .audit-more { display:block; margin:14px auto 0; }
16242
- .audit-foot { font-size:var(--fs-xs); margin-top:18px; line-height:1.6; }
16881
+ /* quiet tail: left-aligned text button while more exists; a rule once exhausted */
16882
+ .audit-more { display:inline-flex; align-items:center; gap:6px; margin:14px 0 0 2px; font-size:var(--fs-sm); color:var(--muted); }
16883
+ .audit-more:hover { color:var(--fg); }
16884
+ .audit-more.loading { opacity:.6; pointer-events:none; }
16885
+ .audit-end { display:flex; align-items:center; gap:12px; margin:18px 0 0; color:var(--muted); font-size:var(--fs-xs); }
16886
+ .audit-end::before, .audit-end::after { content:""; flex:1; height:1px; background:var(--line); }
16243
16887
  @media (hover:none) { .audit-revert { opacity:1; } }
16244
16888
  .set-callout.warn { border-color:color-mix(in srgb,var(--warning,#9a6700) 32%,var(--line)); }
16245
- /* two-fact meta line under a block title (who publishes \xB7 where creds live) \u2014
16246
- answers the questions hiding under "is this bucket for server or browser?" */
16247
- .set-meta { display:flex; flex-wrap:wrap; gap:8px 16px; margin:0 0 12px; }
16248
- .set-meta-item { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--fg-soft); }
16249
- .set-meta-item svg { width:14px; height:14px; color:var(--accent); }
16250
16889
  /* a quiet helper line under a field / chip group */
16251
16890
  .set-hint { font-size:var(--fs-xs); color:var(--muted); line-height:1.5; margin:3px 0 4px; }
16252
16891
  /* progressive disclosure ("\u5DE5\u4F5C\u539F\u7406" / "\u8FDB\u9636") \u2014 native details, themed */
@@ -16331,58 +16970,39 @@ var styles_default = ` :root {
16331
16970
  .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16332
16971
  .peer-menu { flex:none; padding:6px; }
16333
16972
 
16334
- /* workspace data-map summary heading \u6570\u636E\u4E0E\u5907\u4EFD (SyncHealthLine): a quiet
16335
- page sub-line, not a callout \u2014 no border, no fill; the dot carries tone. */
16336
- .sync-health { margin:0; }
16337
- .sync-health-line { display:inline-flex; align-items:center; gap:8px; max-width:100%; padding:2px 0;
16338
- background:none; border:0; cursor:pointer; text-align:left; font:inherit; color:var(--fg-soft); }
16339
- .sync-health-line:hover { color:var(--fg); }
16340
- .sync-health-line:hover .sync-health-more { color:var(--fg); }
16973
+ /* \u6570\u636E\u4E0E\u5907\u4EFD health sentence (PageHeader sub): the dot alone carries tone */
16974
+ .sync-health-sub { display:inline-flex; align-items:center; gap:8px; max-width:100%; min-width:0; }
16341
16975
  .sync-health-dot { flex:none; width:8px; height:8px; border-radius:50%; }
16342
16976
  .sync-health-dot.dot-ok { background:var(--success); }
16343
16977
  .sync-health-dot.dot-warn { background:var(--warning,#9a6700); }
16344
16978
  .sync-health-dot.dot-error { background:var(--danger); }
16345
- .sync-health-text { min-width:0; font-size:var(--fs-sm); font-weight:500;
16346
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16347
- .sync-health-more { flex:none; font-size:var(--fs-xs); color:var(--muted); }
16348
- /* shared expander chevron (SyncHealthLine \u8BE6\u60C5, device roster rows) */
16979
+ /* shared expander chevron (bucket + device roster rows) */
16349
16980
  .row-chev { flex:none; color:var(--muted); display:flex; transition:transform .15s; }
16350
16981
  .row-chev .ico { width:14px; height:14px; }
16351
16982
  .row-chev.open { transform:rotate(180deg); }
16352
16983
  /* confirm dialogs: keep \\n line breaks (consequence lists) */
16353
16984
  .cfm-msg { white-space: pre-line; }
16354
- /* concurrent problems under the headline (only when >1, else redundant) */
16355
- .sync-health-issues { padding:2px 0 4px; }
16356
- .sync-health-issue { font-size:12px; color:var(--muted); padding:2px 0 2px 18px; }
16357
- .sync-health-places { padding:4px 0 2px; }
16358
- .sync-health-place { display:flex; align-items:baseline; gap:8px; padding:8px 0; min-width:0; }
16359
- .sync-health-place + .sync-health-place { border-top:1px solid var(--line); }
16360
- .sync-health-place-kind { flex:none; font-size:11px; color:var(--muted); border:1px solid var(--line);
16361
- border-radius:999px; padding:1px 7px; }
16362
- .sync-health-place-label { font-size:var(--fs-body); font-weight:550; overflow:hidden;
16363
- text-overflow:ellipsis; white-space:nowrap; }
16364
- .sync-health-place-cap { flex:1; min-width:0; font-size:12px; color:var(--muted);
16365
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16366
- .sync-health-role { flex:none; font-size:11px; color:var(--accent); background:var(--accent-soft);
16367
- border-radius:999px; padding:2px 7px; }
16368
-
16369
- /* unified device roster (DevicesPanel) */
16370
- .device-detail { padding:2px 12px 10px 28px; display:flex; flex-direction:column; gap:4px; }
16371
- .device-detail-line { display:flex; align-items:center; gap:8px; font-size:12px; min-width:0; }
16372
- .device-ch-label { flex:none; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; font-size:11px; }
16373
- .device-ch-ref { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--fg); }
16374
- .device-ch-actions { margin-left:auto; display:inline-flex; gap:2px; flex:none; }
16375
- .device-ch-actions .btn { padding:2px 8px; font-size:12px; }
16376
- .device-danger { color:var(--danger); }
16377
- .device-detail-note { font-size:12px; color:var(--muted); background:var(--surface-2);
16378
- border-radius:8px; padding:8px 10px; margin-top:4px; line-height:1.6; }
16985
+
16986
+ /* device roster (settings/devices-page.tsx): expanded detail = key/value
16987
+ table + one action row + one plain sentence \u2014 no gray callout box. */
16988
+ .device-kv { display:grid; grid-template-columns:max-content 1fr; gap:6px 18px; padding:4px 0 10px 42px; font-size:var(--fs-sm); margin:0; }
16989
+ .device-kv dt { color:var(--muted); margin:0; }
16990
+ .device-kv dd { margin:0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16991
+ .device-kv .mono { font-family:var(--mono); font-size:var(--fs-xs); letter-spacing:-.01em; }
16992
+ .device-actions { display:flex; align-items:center; gap:4px; padding-left:42px; flex-wrap:wrap; }
16993
+ .device-actions .btn { padding:3px 8px; font-size:12px; }
16994
+ .device-actions .btn.danger { color:var(--danger); }
16995
+ .device-actions .btn.danger:hover { background:var(--danger-soft); color:var(--danger); }
16996
+ .device-note { font-size:12px; color:var(--muted); line-height:1.6; padding:4px 0 6px 42px; max-width:60ch; }
16997
+ .device-note b { color:var(--fg-soft); font-weight:600; }
16998
+ .device-note a { color:var(--accent); text-decoration:none; }
16999
+ .device-note a:hover { text-decoration:underline; }
16379
17000
 
16380
17001
  /* cloud accessories panel (CloudPanel: bucket + edge in one glance) */
16381
17002
  .cloud-cta { border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:4px 0 12px;
16382
17003
  display:grid; grid-template-columns:1fr auto; gap:4px 12px; align-items:center; background:var(--surface-1); }
16383
17004
  .cloud-cta-title { font-weight:650; font-size:var(--fs-body); }
16384
17005
  .cloud-cta-sub { font-size:12px; color:var(--muted); margin-top:2px; line-height:1.6; }
16385
- .cloud-cta-alt { grid-column:1 / -1; font-size:12px; }
16386
17006
 
16387
17007
  /* printable recovery card */
16388
17008
  .recovery-card { border:2px solid var(--fg); border-radius:12px; padding:16px 18px; margin-top:8px; background:var(--surface); }
@@ -16429,6 +17049,8 @@ var styles_default = ` :root {
16429
17049
  .crumb-link { flex:none; padding:2px 5px; margin:-2px -3px; border-radius:var(--radius-sm); font-weight:500; }
16430
17050
  .crumb-link:hover { background:var(--hover); }
16431
17051
  .crumb-sep { flex:none; color:var(--muted); font-weight:400; }
17052
+ .crumb-title { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17053
+ .crumb-more { color:var(--muted); }
16432
17054
  .iconbtn { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-sm); color:var(--fg-soft); transition:background .12s; }
16433
17055
  .iconbtn:hover { background:var(--hover-2); color:var(--fg); }
16434
17056
  /* Dead end of the history (topbar back/forward). pointer-events:none also
@@ -16450,6 +17072,7 @@ var styles_default = ` :root {
16450
17072
  .btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
16451
17073
  .btn-primary:hover { filter:brightness(1.07); }
16452
17074
  .btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
17075
+ .btn.sm { padding:3px 8px; font-size:12px; }
16453
17076
  /* Notion-style ambient sync indicator: a quiet ghost control living in the
16454
17077
  topbar icon row. State reads through the *text/icon colour* \u2014 no filled pill
16455
17078
  or drop shadow \u2014 and only the error state escalates to a visible danger tone.
@@ -16479,6 +17102,26 @@ var styles_default = ` :root {
16479
17102
  @keyframes save-pop { 0% { transform:scale(.7); } 100% { transform:scale(1); } }
16480
17103
  @keyframes save-shake { 10%,90% { transform:translateX(-1px); } 20%,80% { transform:translateX(1.5px); } 30%,50%,70% { transform:translateX(-2px); } 40%,60% { transform:translateX(2px); } }
16481
17104
  @keyframes save-kbd-pulse { 0% { transform:scale(1); } 45% { transform:scale(1.05); } 100% { transform:scale(1); } }
17105
+ /* Ambient replica-sync indicator (sync-indicator.tsx): same ghost-control
17106
+ idiom as .share-save \u2014 zero footprint when idle, colour carries state,
17107
+ only error escalates to danger. Lives in .sb-footer (desktop), .sb-head
17108
+ (mobile home) and the topbar (sidebar collapsed). */
17109
+ .sync-ind { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 8px; border-radius:var(--radius-sm);
17110
+ font-size:var(--fs-xs); color:var(--fg-soft); white-space:nowrap; font-variant-numeric:tabular-nums; flex:none;
17111
+ animation:save-rise .22s ease both; }
17112
+ .sync-ind:hover { background:var(--hover-2); color:var(--fg); }
17113
+ .sync-ind-ico { display:inline-flex; align-items:center; }
17114
+ .sync-ring { width:12px; height:12px; border-radius:50%; border:1.5px solid var(--line-strong);
17115
+ border-top-color:var(--accent); animation:mh-spin .8s linear infinite; }
17116
+ .sync-ind-done { color:var(--success); }
17117
+ .sync-ind-done .sync-ind-ico { animation:save-pop .24s cubic-bezier(.18,.9,.24,1.22) both; }
17118
+ .sync-ind-error { color:var(--danger); }
17119
+ .sync-ind-error:hover { background:var(--danger-soft); color:var(--danger); }
17120
+ /* footer: push right, version stays flush right after it */
17121
+ .sb-footer .sync-ind { margin-left:auto; }
17122
+ .sb-footer .sync-ind + .sbf-ver { margin-left:8px; }
17123
+ /* head slot is mobile-only (mirrors the .sb-head .iconbtn gating) */
17124
+ .sb-head .sync-ind { display:none; }
16482
17125
 
16483
17126
  /* macOS desktop (hiddenInset frameless): drop the sidebar logo and reserve a
16484
17127
  draggable strip so the inset traffic lights never overlap our controls.
@@ -16501,7 +17144,7 @@ var styles_default = ` :root {
16501
17144
  /* document editor */
16502
17145
  .doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
16503
17146
  .doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
16504
- .doc-title:empty::before { content:"\u65E0\u6807\u9898"; color:var(--muted); }
17147
+ .doc-title:empty::before { content:attr(data-placeholder); color:var(--muted); }
16505
17148
  /* Backstop for the plain-text titles (see plain-edit.ts): should markup ever
16506
17149
  slip into one of these hosts, its inline font-size can't shrink the title. */
16507
17150
  .doc-title *, .db-title * { font:inherit; color:inherit; background:none; }
@@ -16991,11 +17634,16 @@ var styles_default = ` :root {
16991
17634
  .db-icon { width:38px; height:38px; border-radius:9px; background:var(--surface-2); display:grid; place-items:center; font-size:20px; }
16992
17635
  .db-title { font-size:27px; font-weight:700; letter-spacing:-.02em; outline:none; }
16993
17636
  .db-desc { color:var(--muted); font-size:13px; margin-top:2px; }
16994
- .views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; }
16995
- .view-tab { display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:var(--fs-ui); font-weight:500; color:var(--muted);
16996
- border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s; }
17637
+ .views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; overflow-x:auto; scrollbar-width:none; }
17638
+ .views::-webkit-scrollbar { display:none; }
17639
+ .view-tab { display:flex; align-items:center; gap:6px; padding:8px 9px; font-size:var(--fs-ui); font-weight:500; color:var(--muted); cursor:pointer;
17640
+ border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s; white-space:nowrap; border-radius:var(--radius-sm) var(--radius-sm) 0 0; }
16997
17641
  .view-tab.active { color:var(--fg); border-bottom-color:var(--accent); }
16998
- .view-tab:hover { color:var(--fg); } .view-tab svg { width:14px; height:14px; }
17642
+ .view-tab:hover { color:var(--fg); background:var(--hover); } .view-tab svg { width:14px; height:14px; }
17643
+ .view-tab .caret { width:12px; height:12px; color:var(--muted); margin-left:-2px; opacity:0; transition:opacity .12s; }
17644
+ .view-tab.active:hover .caret { opacity:1; }
17645
+ .view-add { width:28px; height:28px; display:grid; place-items:center; color:var(--muted); border-radius:var(--radius-sm); margin-left:2px; flex:none; }
17646
+ .view-add:hover { background:var(--hover-2); color:var(--fg); }
16999
17647
  /* \u6D6E\u7A97\u770B\u677F (right end of the toolbar / board bar, desktop only): a quiet
17000
17648
  Notion-style secondary pill \u2014 visible as a feature entry, never louder
17001
17649
  than \u65B0\u5EFA. Hover lifts the pill and nudges the icon's top kanban card up
@@ -17011,12 +17659,56 @@ var styles_default = ` :root {
17011
17659
  .popout-btn:active { transform:translateY(0); box-shadow:var(--shadow-sm); }
17012
17660
  .popout-btn .i-card { transition:transform .18s ease; }
17013
17661
  .popout-btn:hover .i-card { transform:translateY(-2px); }
17014
- .toolbar { display:flex; align-items:center; gap:2px; padding:10px 0; }
17662
+ .toolbar { display:flex; align-items:center; gap:2px; padding:8px 0 6px; flex-wrap:wrap; }
17015
17663
  .toolbar .spacer { flex:1; }
17016
- .tbtn { display:flex; align-items:center; gap:6px; padding:5px 9px; border-radius:var(--radius-sm); color:var(--fg-soft); font-size:13px; font-weight:500; }
17664
+ .tbtn { display:flex; align-items:center; gap:6px; padding:5px 9px; border-radius:var(--radius-sm); color:var(--fg-soft); font-size:13px; font-weight:500; white-space:nowrap; }
17017
17665
  .tbtn:hover { background:var(--hover-2); color:var(--fg); }
17018
17666
  .tbtn.on { color:var(--accent); }
17019
- .tablewrap { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-sm); }
17667
+ .tbtn.on:hover { background:var(--accent-soft); }
17668
+ .tbtn.icon { padding:5px 7px; }
17669
+ .tb-n { display:inline-grid; place-items:center; min-width:17px; height:17px; padding:0 5px; border-radius:999px; background:var(--accent-soft); color:var(--accent);
17670
+ font-size:var(--fs-micro); font-weight:700; font-variant-numeric:tabular-nums; }
17671
+ .tb-search { display:flex; align-items:center; gap:6px; padding:4px 8px; margin-left:2px; border:1px solid var(--line-strong); border-radius:var(--radius-sm); background:var(--bg);
17672
+ color:var(--muted); min-width:220px; transition:border-color .12s, box-shadow .12s; animation:pal-in .13s ease; }
17673
+ .tb-search:focus-within { border-color:var(--accent); color:var(--accent); box-shadow:0 0 0 2.5px color-mix(in srgb, var(--accent) 16%, transparent); }
17674
+ .tb-search input { flex:1; width:0; min-width:0; border:0; outline:none; background:transparent; font:inherit; font-size:13px; color:var(--fg); }
17675
+ .tb-search .clear { display:grid; place-items:center; width:18px; height:18px; border-radius:50%; color:var(--muted); flex:none; }
17676
+ .tb-search .clear:hover { background:var(--hover-2); color:var(--fg); }
17677
+ /* active filter / sort chips under the toolbar (Notion's rule row) */
17678
+ .tb-chips { display:flex; align-items:center; flex-wrap:wrap; gap:6px; padding:0 0 10px; }
17679
+ .chip-rule { display:inline-flex; align-items:center; gap:5px; padding:3px 6px 3px 8px; border:1px solid var(--line-strong); border-radius:999px; background:var(--bg);
17680
+ font-size:var(--fs-sm); color:var(--fg-soft); max-width:360px; transition:border-color .12s, background .12s; }
17681
+ .chip-rule:hover { border-color:var(--accent); background:var(--accent-soft); }
17682
+ .chip-rule > svg { width:13px; height:13px; color:var(--muted); flex:none; }
17683
+ .chip-rule .k { font-weight:600; color:var(--fg); white-space:nowrap; }
17684
+ .chip-rule .v { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17685
+ .chip-rule .x { display:grid; place-items:center; width:16px; height:16px; border-radius:50%; color:var(--muted); margin-left:2px; }
17686
+ .chip-rule .x:hover { background:var(--hover-2); color:var(--fg); }
17687
+ .chip-rule.op { color:var(--accent); border-style:dashed; padding:3px 9px; font-weight:600; }
17688
+ .chip-rule.sort .k::before { content:""; }
17689
+ /* filter rule editor popover */
17690
+ .fr { min-width:272px; }
17691
+ .fr .lbl { display:flex; align-items:center; gap:6px; text-transform:none; letter-spacing:0; font-size:var(--fs-sm); color:var(--fg); }
17692
+ .fr-conds { display:flex; flex-wrap:wrap; gap:4px; padding:2px 4px 8px; }
17693
+ .fr-cond { padding:3px 9px; border-radius:999px; border:1px solid var(--line); font-size:var(--fs-sm); color:var(--fg-soft); background:var(--bg); white-space:nowrap; }
17694
+ .fr-cond:hover { background:var(--hover-2); color:var(--fg); }
17695
+ .fr-cond.on { background:var(--accent); border-color:var(--accent); color:var(--accent-fg); }
17696
+ .fr .dp { padding:0; }
17697
+ .fr-refs { display:flex; flex-wrap:wrap; gap:4px; padding:4px 8px 6px; font-size:var(--fs-sm); }
17698
+ .pop input.field { font:inherit; font-size:var(--fs-ui); color:var(--fg); }
17699
+ /* sort rows + property visibility rows in their popovers */
17700
+ .sortrow .sortname { flex:1; display:flex; align-items:center; gap:6px; min-width:0; font-size:var(--fs-ui); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17701
+ .sortrow .sortname svg { color:var(--muted); flex:none; }
17702
+ .sortrow .sortdir { display:inline-flex; align-items:center; gap:4px; padding:3px 7px; border-radius:var(--radius-sm); font-size:var(--fs-sm); color:var(--fg-soft); white-space:nowrap; }
17703
+ .sortrow .sortdir:hover { background:var(--hover-2); color:var(--fg); }
17704
+ .flipv { transform:scaleY(-1); }
17705
+ .proprow-v .optlabel { gap:6px; align-items:center; font-size:var(--fs-ui); color:var(--fg); }
17706
+ .proprow-v .optlabel svg { color:var(--muted); flex:none; }
17707
+ .proprow-v .optlabel .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17708
+ .proprow-v.off .optlabel { color:var(--muted); }
17709
+ .proprow-v .x:disabled { opacity:.35; cursor:default; }
17710
+ .proprow-v .x:disabled:hover { background:none; color:var(--muted); }
17711
+ .tablewrap { border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
17020
17712
  .tablescroll { overflow-x:auto; }
17021
17713
  /* fixed layout + max-content: each <col> width is authoritative, so the table
17022
17714
  is exactly the sum of its columns and resizing one column never redistributes
@@ -17025,12 +17717,26 @@ var styles_default = ` :root {
17025
17717
  table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
17026
17718
  table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
17027
17719
  table.grid th:last-child, table.grid td:last-child { border-right:0; }
17028
- table.grid tbody tr:last-child td { border-bottom:0; }
17029
- thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
17030
- .colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
17031
- .colhead:hover { background:var(--hover); }
17032
- .colhead .ti { color:var(--muted); } .colhead .ti svg { width:14px; height:14px; }
17720
+ table.grid tbody tr:last-child td { border-bottom:1px solid var(--line); }
17721
+ thead th { background:var(--bg); position:relative; user-select:none; font-weight:500; color:var(--muted); }
17722
+ .colhead { display:flex; align-items:center; gap:7px; padding:8px 11px; font-size:13px; cursor:pointer; min-height:36px; }
17723
+ .colhead:hover { background:var(--hover); color:var(--fg-soft); }
17724
+ .colhead .ti { color:var(--muted); display:flex; } .colhead .ti svg { width:14px; height:14px; }
17033
17725
  .colhead .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17726
+ .colhead .sortmark { width:12px; height:12px; color:var(--accent); flex:none; }
17727
+ .colhead .caret { width:12px; height:12px; color:var(--muted); flex:none; opacity:0; transition:opacity .12s; }
17728
+ .colhead:hover .caret { opacity:1; }
17729
+ table.grid.nowrap .cell { flex-wrap:nowrap; overflow:hidden; }
17730
+ table.grid.nowrap .cell .txt { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17731
+ table.grid tfoot td { border-right:0; border-bottom:0; background:var(--bg); }
17732
+ table.grid tfoot td.calc { height:32px; cursor:pointer; padding:0 11px; font-size:var(--fs-xs); color:var(--muted); text-align:right; white-space:nowrap; user-select:none; }
17733
+ table.grid tfoot td.calc:hover { background:var(--hover); }
17734
+ table.grid tfoot td.calc .k { display:inline-flex; align-items:center; gap:2px; }
17735
+ table.grid tfoot td.calc .k.ghost { opacity:0; transition:opacity .12s; }
17736
+ table.grid tfoot td.calc .k.ghost svg { width:11px; height:11px; }
17737
+ table.grid tfoot:hover td.calc .k.ghost { opacity:.75; }
17738
+ table.grid tfoot td.calc.on .k { margin-right:6px; }
17739
+ table.grid tfoot td.calc.on .v { color:var(--fg); font-weight:600; font-variant-numeric:tabular-nums; }
17034
17740
  .col-resizer { position:absolute; top:0; right:-4px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
17035
17741
  .addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
17036
17742
  /* trailing filler column: empty canvas to the right of the data, not a real cell */
@@ -17038,19 +17744,24 @@ var styles_default = ` :root {
17038
17744
  body.col-resizing { user-select:none; cursor:col-resize; }
17039
17745
  table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
17040
17746
  /* relative: anchors the .celledit overlay editor to the cell */
17041
- td.cell-td { position:relative; vertical-align:top; }
17747
+ td.cell-td { position:relative; vertical-align:top; user-select:none; }
17748
+ td.cell-td .celledit, td.cell-td .celledit * { user-select:text; }
17042
17749
  .cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
17043
17750
  /* hover/active tint goes on the <td> (always full row height) not the inner
17044
17751
  .cell (only as tall as its content), so a taller sibling row never leaves a
17045
17752
  gap of un-tinted space at the bottom of the hovered cell. */
17046
17753
  td.cell-td { transition:background .1s; }
17047
- td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
17754
+ .cell.center { justify-content:center; }
17048
17755
  tbody tr { transition:background .08s; }
17049
17756
  tbody tr:hover { background:var(--hover); }
17050
17757
  tbody tr.sel { background:var(--accent-soft); }
17051
17758
  /* cell range selection: per-cell fill; the accent rectangle outline is drawn via inline box-shadow on edge cells. Placed after :hover so it wins on equal specificity. */
17052
17759
  td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
17053
17760
  body.cell-selecting { user-select:none; cursor:cell; }
17761
+ td.cell-td.fillsel { background:var(--accent-soft); box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); }
17762
+ .cell-fill-handle { position:absolute; right:-4px; bottom:-4px; width:8px; height:8px; border-radius:2px; background:var(--accent);
17763
+ border:1.5px solid var(--bg); cursor:crosshair; z-index:4; touch-action:none; }
17764
+ .cell-fill-handle::after { content:""; position:absolute; inset:-6px; }
17054
17765
  body.cell-selecting .cell { cursor:cell; }
17055
17766
  /* column reorder still marks the target <th>; rows use the .rowdrop line below */
17056
17767
  table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
@@ -17106,23 +17817,57 @@ var styles_default = ` :root {
17106
17817
  /* data-resolved (not prefers-color-scheme): tracks a manual dark choice too. */
17107
17818
  :root[data-resolved="dark"] .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); }
17108
17819
  /* relation chip: an <a> that jumps to the target record */
17109
- a.chip.rel { text-decoration:none; cursor:pointer; }
17820
+ a.chip.rel { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; text-decoration:none; cursor:pointer; }
17110
17821
  a.chip.rel:hover { background:color-mix(in srgb,var(--c) 26%,transparent); text-decoration:underline; text-decoration-style:dashed; text-underline-offset:2px; }
17111
17822
  /* doc chip whose target is deleted/unsynced: muted + dashed, same cue as the
17112
17823
  editor's .cm-doclink-missing pill */
17113
17824
  a.chip.rel.doc-missing { background:transparent; color:var(--muted); outline:1px dashed var(--line-strong); outline-offset:-1px; }
17114
- .addrow { display:flex; align-items:center; gap:7px; padding:9px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-top:1px solid var(--line); }
17825
+ .addrow { display:flex; align-items:center; gap:7px; padding:8px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-radius:var(--radius-sm); margin-top:2px; }
17115
17826
  .addrow:hover { background:var(--hover); color:var(--fg-soft); }
17827
+ .addrow svg { width:14px; height:14px; }
17116
17828
  .gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
17117
- input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
17829
+ .inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:0; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
17830
+ textarea.inlineedit { resize:none; overflow:hidden; line-height:1.45; display:block; }
17831
+ .inlineedit.num { text-align:right; font-variant-numeric:tabular-nums; }
17118
17832
  /* Overlay cell editor: floats over the td; the display content underneath
17119
17833
  stays in flow so the row height never changes while editing. Padding on the
17120
- input matches .cell (8px 11px) so the text doesn't shift on entering edit. */
17834
+ input matches .cell (8px 11px) so the text doesn't shift on entering edit;
17835
+ a multi-line text editor grows downward over the rows below. */
17121
17836
  .celledit { position:absolute; top:0; left:0; width:100%; min-height:100%; z-index:10;
17122
- display:flex; align-items:stretch; background:var(--bg); border-radius:var(--radius-sm);
17123
- box-shadow:0 0 0 2px var(--accent), var(--shadow-md); }
17124
- .celledit input.inlineedit { width:100%; min-width:0; border:0; outline:0; border-radius:inherit;
17837
+ display:flex; align-items:stretch; background:var(--bg); border-radius:0;
17838
+ box-shadow:inset 0 0 0 2px var(--accent), var(--shadow-md); }
17839
+ .celledit .inlineedit { width:100%; min-width:0; border:0; outline:0; border-radius:inherit;
17125
17840
  padding:8px 11px; background:transparent; }
17841
+ .celledit textarea.inlineedit { min-height:37px; }
17842
+ .cell.num { justify-content:flex-end; font-variant-numeric:tabular-nums; }
17843
+ .cell .txt { white-space:pre-wrap; }
17844
+ .cell-url { display:inline-flex; align-items:center; gap:4px; max-width:100%; min-width:0; color:var(--accent); text-decoration:none; }
17845
+ .cell-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17846
+ .cell-url svg { flex:none; width:12px; height:12px; opacity:0; transition:opacity .1s; }
17847
+ .cell-url:hover span { text-decoration:underline; text-underline-offset:2px; }
17848
+ td.cell-td:hover .cell-url svg { opacity:.7; }
17849
+ /* loading skeleton: the real grid's cells, sheen bars for content */
17850
+ table.grid.skel { pointer-events:none; }
17851
+ table.grid.skel .skel-t { display:block; height:13px; width:calc(38% + var(--i, 0) * 11%); border-radius:4px; }
17852
+ table.grid.skel th .skel-t { height:12px; width:calc(30% + var(--i, 0) * 9%); }
17853
+ /* date picker (date-picker.tsx) */
17854
+ .dp { padding:2px; }
17855
+ .dp .selsearch.invalid { border-color:var(--danger); }
17856
+ .dp-nav { display:flex; align-items:center; gap:4px; padding:2px 2px 6px; }
17857
+ .dp-nav .iconbtn.sm { width:26px; height:26px; }
17858
+ .dp-title { flex:1; text-align:center; font-size:var(--fs-ui); font-weight:600; color:var(--fg); }
17859
+ .dp-grid { display:grid; grid-template-columns:repeat(7, 32px); gap:2px; justify-content:center; }
17860
+ .dp-head span { text-align:center; font-size:var(--fs-micro); color:var(--muted); font-weight:600; padding:2px 0 4px; }
17861
+ .dp-day { width:32px; height:30px; border-radius:var(--radius-sm); font-size:var(--fs-sm); color:var(--fg); display:grid; place-items:center;
17862
+ font-variant-numeric:tabular-nums; }
17863
+ .dp-day.dim { color:var(--muted); opacity:.55; }
17864
+ .dp-day:hover { background:var(--hover-2); }
17865
+ .dp-day.focus { box-shadow:inset 0 0 0 1.5px var(--accent); }
17866
+ .dp-day.today { font-weight:700; color:var(--accent); }
17867
+ .dp-day.picked { background:var(--accent); color:var(--accent-fg); }
17868
+ .dp-day.picked:hover { background:var(--accent); }
17869
+ .dp-foot { display:flex; align-items:center; gap:2px; padding:8px 2px 2px; margin-top:4px; border-top:1px solid var(--line); }
17870
+ .dp-hint { margin-left:auto; font-size:var(--fs-xs); color:var(--muted); padding-right:4px; }
17126
17871
 
17127
17872
  /* ---- sites (\u7AD9\u70B9\u7BA1\u7406) ---- */
17128
17873
  .sites-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(264px, 1fr)); gap:14px; margin-top:4px; }
@@ -17168,55 +17913,148 @@ var styles_default = ` :root {
17168
17913
  .site-empty .et { font-size:15px; color:var(--fg); margin-bottom:5px; font-weight:650; letter-spacing:-.01em; }
17169
17914
  .site-empty .ed { font-size:var(--fs-sm); margin-bottom:18px; max-width:42ch; line-height:1.55; }
17170
17915
 
17171
- /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
17172
- .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:18px; }
17173
- .shv-tools { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
17916
+ /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17917
+ One row = object \xB7 status \xB7 one primary action \xB7 overflow. State is the
17918
+ organizing idea: colour lives only in the status dot and the \u53EF\u7F16\u8F91 word;
17919
+ expiry is a share's normal ending and reads muted, never danger. */
17920
+ .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:6px; }
17921
+ .shares-page .db-desc { max-width:56ch; }
17922
+ .shv-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:nowrap; padding:10px 0; margin-bottom:8px; }
17923
+ .shv-tools-right { display:flex; align-items:center; gap:10px; flex:none; margin-left:auto; }
17174
17924
  .shv-seg { display:inline-flex; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius-sm); padding:2px; }
17175
- .shv-seg-btn { padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
17925
+ .shv-seg-btn { display:inline-flex; align-items:baseline; padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
17176
17926
  .shv-seg-btn.active { background:var(--bg); color:var(--fg); box-shadow:var(--shadow-sm); }
17177
- .shv-search { display:flex; align-items:center; gap:6px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
17178
- .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:180px; }
17927
+ .shv-seg-btn .n { display:inline-flex; justify-content:flex-end; align-items:center; min-width:2ch; margin-left:5px; font-family:var(--mono); font-size:var(--fs-micro); color:var(--muted); font-variant-numeric:tabular-nums; }
17928
+ .shv-seg-btn .skel-n { width:2ch; height:.85em; border-radius:3px; animation:skel-in .25s ease .12s both; }
17929
+ .shv-seg-btn.active .n { color:var(--fg-soft); }
17930
+ .shv-src { gap:5px; color:var(--fg-soft); }
17931
+ .shv-src.on { color:var(--fg); }
17932
+ .shv-src .ico { width:14px; height:14px; opacity:.7; }
17933
+ .shv-search { display:flex; align-items:center; gap:6px; flex:1 1 220px; min-width:140px; max-width:360px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
17934
+ .shv-search:focus-within { border-color:var(--accent); }
17935
+ .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:100%; min-width:0; }
17179
17936
 
17180
17937
  .shv-list { display:flex; flex-direction:column; gap:8px; list-style:none; padding:0; margin:0; }
17181
- .shv-row { display:flex; align-items:center; gap:13px; padding:13px 15px; background:var(--bg); border:1px solid var(--line);
17938
+ .shv-row { display:grid; grid-template-columns:38px minmax(0,1fr) auto auto; gap:6px 13px; align-items:center;
17939
+ padding:12px 15px; background:var(--bg); border:1px solid var(--line);
17182
17940
  border-radius:var(--radius-lg); transition:border-color .12s, box-shadow .12s; }
17183
17941
  .shv-row:hover { border-color:var(--line-strong); box-shadow:var(--shadow-sm); }
17184
17942
  .shv-ico { width:38px; height:38px; flex:none; display:grid; place-items:center; border-radius:var(--radius-md, 10px);
17185
17943
  background:var(--surface-2); color:var(--fg-soft); }
17186
17944
  .shv-ico svg { width:19px; height:19px; }
17187
17945
  .shv-ico.k-doc { color:var(--accent); background:var(--accent-soft); }
17946
+ .shv-ico.k-database { color:var(--fg-soft); }
17188
17947
  .shv-ico.k-site { color:#1a7f5a; }
17189
- .shv-main { flex:1; min-width:0; }
17190
- .shv-titleline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
17191
- .shv-title { font-weight:600; color:var(--fg); letter-spacing:-.01em; max-width:34ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17948
+ .shv-main { min-width:0; }
17949
+ .shv-title { display:block; max-width:100%; font-weight:600; color:var(--fg); letter-spacing:-.01em;
17950
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
17192
17951
  .shv-title:hover { color:var(--accent); }
17193
- .shv-pill { font-size:11px; line-height:1.6; padding:0 8px; border-radius:999px; border:1px solid var(--line-strong); color:var(--fg-soft); }
17194
- .shv-pill.t-s3 { background:var(--accent-soft); border-color:transparent; color:var(--accent); }
17195
- .shv-pill.p-edit { background:var(--danger-soft); border-color:transparent; color:var(--danger); }
17196
- .shv-lock { font-size:12px; }
17197
- .shv-meta { margin-top:3px; font-size:var(--fs-sm); color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17198
- .shv-exp.warn { color:var(--danger); }
17199
- .shv-exp.dead { text-decoration:line-through; }
17952
+ .shv-sub { display:flex; align-items:center; gap:6px; margin-top:3px; min-width:0; font-size:var(--fs-sm); color:var(--muted);
17953
+ white-space:nowrap; overflow:hidden; }
17954
+ .shv-sub > * { flex:none; }
17955
+ .shv-sub > .shv-link { flex:0 1 auto; }
17956
+ .shv-link-text { min-width:0; overflow:hidden; text-overflow:ellipsis; }
17957
+ .shv-sep { color:var(--line-strong); }
17958
+ /* the link itself: mono, copy on click, copy glyph slides in on hover */
17959
+ .shv-link { display:inline-flex; align-items:center; gap:4px; min-width:0; max-width:46ch; font-family:var(--mono); font-size:var(--fs-xs);
17960
+ color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; border-radius:4px; }
17961
+ .shv-link .ico { width:12px; height:12px; opacity:0; transition:opacity .12s; flex:none; }
17962
+ .shv-link:not(:disabled):hover { color:var(--accent); }
17963
+ .shv-link:not(:disabled):hover .ico { opacity:1; }
17964
+ .shv-link:disabled { cursor:default; text-decoration:line-through; text-decoration-color:var(--line-strong); }
17965
+ .shv-snap { font-variant-numeric:tabular-nums; }
17966
+ .shv-perm.edit { color:var(--danger); font-weight:500; }
17967
+ .shv-lock { display:inline-flex; color:var(--fg-soft); }
17968
+ .shv-lock .ico { width:12px; height:12px; }
17969
+ /* status column: dot + short text, fixed floor so rows never jitter */
17970
+ .shv-status { display:inline-flex; align-items:center; gap:7px; min-width:9ch; font-size:var(--fs-sm); color:var(--fg-soft);
17971
+ font-variant-numeric:tabular-nums; white-space:nowrap; }
17972
+ .shv-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); flex:none; }
17973
+ .shv-status.tone-ok .shv-dot { background:var(--success); }
17974
+ .shv-status.tone-warn { color:var(--warning,#9a6700); }
17975
+ .shv-status.tone-warn .shv-dot { background:var(--warning,#9a6700); }
17976
+ .shv-status.tone-muted { color:var(--muted); }
17977
+ .shv-status.tone-muted .shv-dot { background:transparent; border:1.5px solid var(--line-strong); }
17978
+ .shv-status .sync-ring { width:10px; height:10px; }
17200
17979
  .shv-actions { display:flex; align-items:center; gap:6px; flex:none; }
17201
- .shv-actions .btn { padding:5px 10px; font-size:12px; }
17980
+ .shv-actions .btn { padding:5px 10px; font-size:12px; min-width:76px; justify-content:center; }
17981
+ .shv-actions .btn.txt-danger { color:var(--danger); }
17982
+ .shv-row.st-expired .shv-title { color:var(--fg-soft); }
17983
+ /* expired group sinks below the live list */
17984
+ .shv-group-head { display:flex; align-items:center; gap:8px; margin:22px 2px 8px; font-size:var(--fs-sm); font-weight:600; color:var(--muted); }
17985
+ .shv-group-head .n { font-family:var(--mono); font-size:var(--fs-micro); font-weight:400; }
17986
+ .shv-group-head .btn { margin-left:auto; padding:4px 10px; font-size:12px; }
17987
+ /* list swap: data arriving over the skeleton fades in rather than hard-cutting */
17988
+ .shv-list { animation:skel-in .18s ease both; }
17989
+
17990
+ /* \u2500\u2500 skeletons (skeleton.tsx, primitives.tsx SetRowSkeleton, shares-view
17991
+ ShareListSkeleton). Rows are the REAL row markup; only content is swapped
17992
+ for sheen blocks. .skel-list fades in after 120ms so an instant load
17993
+ never flashes it; --i offsets each row's sheen into one rolling wave. */
17994
+ .skel-list { animation:skel-in .25s ease .12s both; }
17995
+ .skel-lines { display:flex; flex-direction:column; gap:8px; }
17996
+ .skel-lines .skel-s { width:calc(58% + var(--i, 0) * 9%); max-width:100%; }
17997
+ .page-header-sub .skel-s { display:block; width:22ch; }
17998
+ .set-row.skel { pointer-events:none; }
17999
+ .set-row.skel .set-row-text { display:flex; flex-direction:column; gap:5px; padding:3px 0; }
18000
+ .set-row.skel .skel-t { height:14px; width:calc(30% + var(--i, 0) * 8%); border-radius:5px; }
18001
+ .set-row.skel .skel-s { height:11px; width:calc(44% + var(--i, 0) * 7%); border-radius:4px; }
18002
+ .set-row.skel .set-row-lead.skel-b { border-radius:7px; }
18003
+ .skel-switch { width:38px; height:22px; border-radius:999px; }
18004
+ .skel-chevron { width:14px; height:14px; border-radius:4px; }
18005
+ .skel-menu { width:28px; height:28px; border-radius:var(--radius-sm); }
18006
+ .skel-button { width:76px; height:29px; border-radius:var(--radius-sm); }
18007
+ .audit-item.skel .audit-item-line { cursor:default; pointer-events:none; }
18008
+ .audit-item.skel .skel-when { width:56px; height:12px; border-radius:4px; flex:none; }
18009
+ .audit-item.skel .skel-t { height:14px; width:calc(34% + var(--i, 0) * 9%); border-radius:5px; }
18010
+ .site-card.skel { pointer-events:none; }
18011
+ .site-card.skel:hover { transform:none; box-shadow:none; border-color:var(--line); }
18012
+ .site-card.skel .si.skel-b { background:var(--surface-2); }
18013
+ .site-card.skel .skel-t { height:14px; width:calc(40% + var(--i, 0) * 8%); border-radius:5px; margin-bottom:6px; }
18014
+ .site-card.skel .skel-s { height:11px; width:calc(55% + var(--i, 0) * 6%); border-radius:4px; }
18015
+ .site-card.skel .skel-addr { height:30px; width:80%; border-radius:var(--radius-sm); }
18016
+ .filerow.skel { pointer-events:none; }
18017
+ .filerow.skel .fi.skel-b { background:var(--surface-2); }
18018
+ .filerow.skel .skel-t { height:13px; width:calc(36% + var(--i, 0) * 10%); border-radius:4px; }
18019
+ .blob-hero.skel .blob-legend .skel-s { height:12px; width:calc(70% + var(--i, 0) * 8%); }
18020
+ .blob-hero.skel .blob-total .skel-s { display:block; height:11px; width:14ch; }
18021
+ .skel-b { display:block; position:relative; overflow:hidden; background:var(--surface-2); border-radius:6px; }
18022
+ .skel-b::after { content:""; position:absolute; inset:0;
18023
+ background:linear-gradient(90deg, transparent 20%, color-mix(in srgb, var(--fg) 5%, transparent) 50%, transparent 80%);
18024
+ background-size:200% 100%; animation:skel-sweep 1.6s ease-in-out infinite;
18025
+ animation-delay:calc(var(--i, 0) * 120ms); }
18026
+ .shv-row.skel { pointer-events:none; }
18027
+ .shv-row.skel:hover { border-color:var(--line); box-shadow:none; }
18028
+ .skel-t { height:1.55em; height:1lh; width:calc(52% + var(--i, 0) * 9%); border-radius:5px; }
18029
+ .skel-s { height:1.55em; height:1lh; width:min(44ch, 60%); border-radius:4px; }
18030
+ .shv-status .skel-st { height:1.55em; height:1lh; width:7ch; border-radius:4px; }
18031
+ .skel-btn { width:76px; height:29px; border-radius:var(--radius-sm); }
18032
+ .skel-ib { width:30px; height:30px; border-radius:var(--radius-sm); }
18033
+ @keyframes skel-in { from { opacity:0; } to { opacity:1; } }
18034
+ @keyframes skel-sweep { from { background-position:100% 0; } to { background-position:-100% 0; } }
17202
18035
  @media (max-width:640px){
17203
- /* head stacks: title/desc full row, then segmented tabs, then search */
18036
+ .shv-actions .skel-btn { flex:1; width:auto; height:36px; }
18037
+ .shv-actions .skel-ib { width:40px; height:36px; }
18038
+ /* head stacks: title/desc full row, then segmented tabs, then [source][search] */
17204
18039
  .shares-page .db-head { flex-direction:column; align-items:stretch; gap:12px; }
17205
- .shv-tools { margin-left:0; width:100%; }
17206
- .shv-seg { display:flex; width:100%; }
17207
- .shv-seg-btn { flex:1; text-align:center; }
17208
- .shv-search { flex:1; }
17209
- .shv-search input { width:100%; flex:1; }
17210
-
17211
- /* cards: title clamps to two lines, meta wraps naturally */
17212
- .shv-row { flex-wrap:wrap; }
17213
- .shv-title { max-width:100%; white-space:normal; display:-webkit-box;
17214
- -webkit-line-clamp:2; -webkit-box-orient:vertical; text-align:left; }
17215
- .shv-meta { white-space:normal; overflow:visible; text-overflow:clip; }
17216
-
17217
- /* actions: full row of evenly stretched touch targets */
17218
- .shv-actions { width:100%; }
17219
- .shv-actions .btn { flex:1; justify-content:center; padding:7px 10px; }
18040
+ .shv-toolbar { flex-wrap:wrap; }
18041
+ .shv-tools-right { display:contents; }
18042
+ .shv-seg { display:flex; width:100%; order:-1; }
18043
+ .shv-seg-btn { flex:1; justify-content:center; padding:6px 4px; }
18044
+ .shv-src { flex:1; justify-content:center; }
18045
+ .shv-search { flex:1; min-width:0; max-width:none; }
18046
+
18047
+ /* rows: ico + main on top; status, then the action row, each full width */
18048
+ .shv-row { grid-template-columns:38px minmax(0,1fr); gap:8px 13px; align-items:start; }
18049
+ .shv-title { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
18050
+ .shv-sub { white-space:normal; flex-wrap:wrap; row-gap:2px; }
18051
+ .shv-link { max-width:100%; }
18052
+ .shv-status { grid-column:1 / -1; min-width:0; }
18053
+ .shv-actions { grid-column:1 / -1; }
18054
+ .shv-actions .btn { flex:1; padding:8px 10px; }
18055
+ .shv-actions .iconbtn { width:40px; height:36px; flex:none; }
18056
+ .shv-group-head { flex-wrap:wrap; }
18057
+ .shv-group-head .btn { margin-left:0; width:100%; justify-content:center; padding:7px 10px; }
17220
18058
  }
17221
18059
 
17222
18060
  /* in-context "\u5DF2\u5206\u4EAB" badge on docs / tables / site cards */
@@ -17378,6 +18216,11 @@ var styles_default = ` :root {
17378
18216
  .proprow .v { flex:1; padding:7px 8px; border-radius:var(--radius-sm); cursor:text; min-height:33px; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
17379
18217
  .proprow .v:hover { background:var(--hover); }
17380
18218
  .peek-divider { height:1px; background:var(--line); margin:22px 0 18px; }
18219
+ .peek-hidden-toggle { display:flex; align-items:center; gap:6px; padding:7px 8px; margin:2px 0; border-radius:var(--radius-sm); color:var(--muted); font-size:13px; width:100%; text-align:left; }
18220
+ .peek-hidden-toggle:hover { background:var(--hover); color:var(--fg-soft); }
18221
+ .peek-hidden-toggle svg { transition:transform .15s; }
18222
+ .peek-hidden-toggle svg.down { transform:rotate(90deg); }
18223
+ .peek-head .peek-nav { display:flex; gap:2px; margin-left:4px; }
17381
18224
  /* linked-document tabs + embedded editor below the peek's property list */
17382
18225
  .peekdocs-tabs { display:flex; align-items:center; gap:4px; flex-wrap:wrap; margin-bottom:6px; }
17383
18226
  .peekdocs-tab { border:0; background:none; font:inherit; font-size:var(--fs-sm); font-weight:500; color:var(--muted);
@@ -17408,7 +18251,7 @@ var styles_default = ` :root {
17408
18251
  .hist-kind.repair { background:var(--danger-soft); color:var(--danger); }
17409
18252
  .hist-now { font-size:var(--fs-xs); padding:1px 6px; border-radius:99px; background:var(--accent); color:var(--accent-fg); }
17410
18253
  .hist-preview { flex:1; overflow:auto; padding:20px 24px; }
17411
- .hist-doc-title { font-size:21px; font-weight:700; letter-spacing:-.02em; margin-bottom:14px; }
18254
+ .hist-doc-title { font-size:21px; font-weight:700; letter-spacing:-.02em; margin-bottom:14px; overflow-wrap:anywhere; }
17412
18255
  .hist-foot { display:flex; justify-content:flex-end; padding:12px 16px; border-top:1px solid var(--line); }
17413
18256
  .pad { padding:12px; }
17414
18257
  .hist-rec-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
@@ -17515,50 +18358,68 @@ var styles_default = ` :root {
17515
18358
  button.fname.link { border:0; background:none; padding:0; text-align:left; cursor:pointer; font-size:inherit; }
17516
18359
  button.fname.link:hover { color:var(--accent); text-decoration:underline; text-decoration-style:dashed; }
17517
18360
 
18361
+ /* key caps (kbd.tsx) */
18362
+ .kbd { display:inline-flex; align-items:center; gap:2px; vertical-align:middle; font-family:var(--kbd-font);
18363
+ font-variant-numeric:tabular-nums; color:var(--muted); white-space:nowrap; }
18364
+ .kbd .key { display:inline-grid; place-items:center; min-width:18px; height:18px; padding:0 4px;
18365
+ border:1px solid var(--line-strong); border-radius:5px; background:var(--surface); font-size:10.5px; font-weight:500; line-height:1; }
18366
+ .kbd .key.sym { font-size:12.5px; }
18367
+
18368
+ /* tooltip (TooltipHost in ui.tsx) */
18369
+ .tip { position:fixed; z-index:118; pointer-events:none; display:inline-flex; align-items:center; gap:8px;
18370
+ padding:5px 8px; border-radius:7px; background:var(--tip-bg); color:var(--tip-fg); font-size:12px; line-height:1.3;
18371
+ white-space:nowrap; box-shadow:var(--shadow-md); animation:tip-in .12s ease-out; }
18372
+ :root[data-resolved="dark"] .tip { border:1px solid var(--line-strong); }
18373
+ .tip[data-side="above"] { animation-name:tip-in-above; }
18374
+ .tip .kbd { color:var(--tip-kbd-fg); }
18375
+ .tip .kbd .key { min-width:16px; height:16px; padding:0 3px; background:var(--tip-kbd-bg); border-color:transparent; font-size:10px; }
18376
+ .tip .kbd .key.sym { font-size:11.5px; }
18377
+ @keyframes tip-in { from { opacity:0; transform:translateY(2px); } }
18378
+ @keyframes tip-in-above { from { opacity:0; transform:translateY(-2px); } }
18379
+
17518
18380
  /* menu / popover */
17519
18381
  .menu-layer { position:fixed; inset:0; z-index:115; }
17520
18382
  .pop { position:fixed; background:var(--bg); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-lg);
17521
- padding:6px; min-width:232px; max-height:70vh; overflow:auto; animation:pop .12s ease; }
18383
+ padding:6px; min-width:232px; max-width:min(420px, calc(100vw - 20px)); max-height:70vh; overflow:auto; animation:pop .12s ease; }
17522
18384
  @keyframes pop { from { opacity:0; transform:translateY(-4px); } }
17523
18385
  /* Action-sheet variant: MenuHost (ui.tsx) adds .sheet on phones, where an
17524
18386
  anchored popover clips at the edges and sits under the finger. */
17525
18387
  .pop.sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom));
17526
- min-width:0; max-height:60vh; max-height:60dvh; border-radius:var(--radius-lg);
18388
+ min-width:0; max-width:none; max-height:60vh; max-height:60dvh; border-radius:var(--radius-lg);
17527
18389
  animation:sheet-in .18s ease; }
17528
18390
  @keyframes sheet-in { from { opacity:0; transform:translateY(12px); } }
17529
- .pop .lbl { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
17530
- .pop .item { display:flex; align-items:center; gap:10px; padding:7px 8px; border-radius:var(--radius-sm); width:100%; text-align:left; color:var(--fg); font-size:var(--fs-ui); }
17531
- .pop .item:hover, .pop .item.sel { background:var(--hover-2); }
17532
- .pop .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
17533
- .pop .item .lico.plain { border:0; width:22px; height:22px; }
17534
- .pop .item .meta { flex:1; min-width:0; }
17535
- .pop .item .t { font-size:var(--fs-ui); } .pop .item .d { font-size:var(--fs-xs); color:var(--muted); display:block; }
17536
- .pop .item .chk { color:var(--accent); margin-left:auto; }
17537
- /* shortcut badge, mirrors .sb-search kbd; margin-left:auto pins it right (and
17538
- a trailing .chk, whose own auto then collapses, rides the gap beside it) */
17539
- .pop .item kbd { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
17540
- .pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
17541
- .pop .sep { height:1px; background:var(--line); margin:5px 4px; }
18391
+ :is(.pop,.palette) .lbl { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
18392
+ :is(.pop,.palette) .item { display:flex; align-items:center; gap:10px; padding:7px 8px; border-radius:var(--radius-sm); width:100%; text-align:left; color:var(--fg); font-size:var(--fs-ui); }
18393
+ :is(.pop,.palette) .item:hover, :is(.pop,.palette) .item.sel { background:var(--hover-2); }
18394
+ :is(.pop,.palette) .item:focus-visible { outline:none; background:var(--hover-2); box-shadow:inset 0 0 0 1.5px var(--accent); }
18395
+ :is(.pop,.palette) .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
18396
+ :is(.pop,.palette) .item .lico.plain { border:0; width:22px; height:22px; }
18397
+ :is(.pop,.palette) .item .meta { flex:1; min-width:0; }
18398
+ :is(.pop,.palette) .item .t { font-size:var(--fs-ui); display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } :is(.pop,.palette) .item .d { font-size:var(--fs-xs); color:var(--muted); display:block; }
18399
+ :is(.pop,.palette) .item .chk { color:var(--accent); margin-left:auto; }
18400
+ :is(.pop,.palette) .item .kbd { margin-left:auto; }
18401
+ :is(.pop,.palette) .item.danger { color:var(--danger); } :is(.pop,.palette) .item.danger .lico { color:var(--danger); }
18402
+ :is(.pop,.palette) .sep { height:1px; background:var(--line); margin:5px 4px; }
17542
18403
  .pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
17543
18404
  .pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
17544
18405
  .pop .typegrid .item .lico { margin:0 auto; }
17545
18406
  .pop input.field { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:7px 9px; outline:none; background:var(--surface); margin:2px; }
17546
18407
  .pop input.field:focus { border-color:var(--accent); }
17547
18408
  /* select-cell picker: search/create input at the top of the popover */
17548
- .pop .selsearch { display:flex; align-items:center; gap:7px; margin:2px 2px 6px; padding:6px 9px;
18409
+ :is(.pop,.palette) .selsearch { display:flex; align-items:center; gap:7px; margin:2px 2px 6px; padding:6px 9px;
17549
18410
  border:1px solid var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); color:var(--muted);
17550
18411
  transition:border-color .12s ease, box-shadow .12s ease, color .12s ease; }
17551
- .pop .selsearch:focus-within { border-color:var(--accent); color:var(--accent);
18412
+ :is(.pop,.palette) .selsearch:focus-within { border-color:var(--accent); color:var(--accent);
17552
18413
  box-shadow:0 0 0 2.5px color-mix(in srgb, var(--accent) 16%, transparent); }
17553
- .pop .selsearch input { flex:1; width:0; min-width:0; border:0; background:transparent; outline:none;
18414
+ :is(.pop,.palette) .selsearch input { flex:1; width:0; min-width:0; border:0; background:transparent; outline:none;
17554
18415
  font:inherit; font-size:var(--fs-ui); color:var(--fg); }
17555
- .pop .selsearch input::placeholder { color:var(--muted); }
17556
- .pop .selsearch .clear { display:grid; place-items:center; width:18px; height:18px; margin:-2px -3px -2px 0;
18416
+ :is(.pop,.palette) .selsearch input::placeholder { color:var(--muted); }
18417
+ :is(.pop,.palette) .selsearch .clear { display:grid; place-items:center; width:18px; height:18px; margin:-2px -3px -2px 0;
17557
18418
  border-radius:50%; color:var(--muted); flex:none; }
17558
- .pop .selsearch .clear:hover { background:var(--hover-2); color:var(--fg); }
18419
+ :is(.pop,.palette) .selsearch .clear:hover { background:var(--hover-2); color:var(--fg); }
17559
18420
  /* relation record picker: fixed-height list \u2014 the popover is positioned once
17560
18421
  on open, so async-loaded rows must scroll instead of growing off-screen */
17561
- .pop .rellist { max-height:264px; overflow-y:auto; }
18422
+ :is(.pop,.palette) .rellist { max-height:264px; overflow-y:auto; }
17562
18423
  .optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
17563
18424
  .optrow .chip { flex:1; }
17564
18425
  .optrow .grip { width:18px; height:22px; display:grid; place-items:center; color:var(--muted); cursor:grab; touch-action:none; flex:none; }
@@ -17603,12 +18464,46 @@ var styles_default = ` :root {
17603
18464
  /* opt-in (openModal aboveMenus): stack one dialog over the menu layer (115)
17604
18465
  so a confirm can appear without dismissing an open popover */
17605
18466
  .modal-scrim.above-menus { z-index:130; }
17606
- .modal { background:var(--bg); border-radius:var(--radius-xl); box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
18467
+ .modal { background:var(--bg); border-radius:var(--radius-xl); box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden;
18468
+ animation:mo-in .13s ease-out; }
18469
+ @keyframes mo-in { from { opacity:0; transform:translateY(6px) scale(.98); } }
18470
+ @keyframes mo-out { to { opacity:0; transform:translateY(4px) scale(.985); } }
18471
+ .modal-scrim.closing .modal { animation:mo-out .12s ease-in forwards; }
18472
+ /* command palette (palette.tsx) */
18473
+ .palette { width:600px; align-self:flex-start; margin-top:11vh; padding:0 8px 0; max-height:min(64vh, 560px);
18474
+ animation:pal-in .13s ease-out; }
18475
+ @keyframes pal-in { from { opacity:0; transform:translateY(-6px) scale(.985); } }
18476
+ .pal-head { display:flex; align-items:center; gap:10px; padding:13px 8px 12px 10px; margin:0 0 6px; border-bottom:1px solid var(--line); color:var(--muted); }
18477
+ .pal-head input { flex:1; width:0; min-width:0; border:0; background:transparent; outline:none; font:inherit; font-size:16px; color:var(--fg); }
18478
+ .pal-head input::placeholder { color:var(--muted); }
18479
+ .pal-head .clear { display:grid; place-items:center; width:20px; height:20px; border-radius:50%; color:var(--muted); flex:none; }
18480
+ .pal-head .clear:hover { background:var(--hover-2); color:var(--fg); }
18481
+ .pal-close { display:none; place-items:center; width:28px; height:28px; border-radius:50%; color:var(--muted); flex:none; }
18482
+ .pal-close:active { background:var(--hover-2); }
18483
+ .pal-list { flex:1; min-height:0; overflow-y:auto; padding-bottom:4px; overscroll-behavior:contain; }
18484
+ .pal-sec + .pal-sec { margin-top:4px; }
18485
+ .palette .item { padding:7px 10px; }
18486
+ .palette .item .lico.plain { color:var(--muted); }
18487
+ :is(.pop,.palette) .item .lico .emo { font-size:15px; line-height:1; }
18488
+ :is(.pop,.palette) .item.sub-r .meta { display:flex; align-items:baseline; gap:12px; }
18489
+ :is(.pop,.palette) .item.sub-r .t { flex:1; }
18490
+ :is(.pop,.palette) .item.sub-r .d { flex:none; max-width:42%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18491
+ .palette .item .d.snip { flex:1 1 100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18492
+ .palette .item:has(.snip) .meta { flex-wrap:wrap; gap:1px 12px; }
18493
+ :is(.pop,.palette) mark { background:transparent; color:var(--accent); font-weight:600; }
18494
+ :is(.pop,.palette) .item .ret { margin-left:auto; flex:none; opacity:0; transition:opacity .1s; }
18495
+ :is(.pop,.palette) .item.sel .ret, :is(.pop,.palette) .item:focus-visible .ret { opacity:1; }
18496
+ :is(.pop,.palette) .pal-empty { padding:22px 12px 20px; text-align:center; color:var(--muted); font-size:var(--fs-ui); }
18497
+ .pal-foot { display:flex; align-items:center; gap:12px; padding:8px 10px 9px; margin:4px -8px 0; border-top:1px solid var(--line); color:var(--muted); font-size:var(--fs-xs); }
18498
+ .pal-foot .keys { display:flex; align-items:center; gap:4px 10px; flex-wrap:wrap; }
18499
+ .pal-foot .kbd { margin-right:2px; }
18500
+ .pal-foot .hint { margin-left:auto; white-space:nowrap; }
17607
18501
  .modal-head { padding:20px 22px 0; }
17608
18502
  .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
17609
18503
  .modal-head p { margin:6px 0 0; color:var(--muted); font-size:var(--fs-ui); }
17610
18504
  .modal-body { padding:18px 22px; overflow-y:auto; }
17611
- .modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
18505
+ .modal-foot { display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
18506
+ .modal-hint { margin-right:auto; display:flex; align-items:center; gap:4px 8px; color:var(--muted); font-size:var(--fs-xs); }
17612
18507
  .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
17613
18508
  .field-label:first-child { margin-top:0; }
17614
18509
  .text-input { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:9px 11px; outline:none; background:var(--surface); font-size:var(--fs-body); }
@@ -17631,6 +18526,12 @@ var styles_default = ` :root {
17631
18526
  .toast { background:var(--fg); color:var(--bg); padding:10px 14px; border-radius:9px; font-size:13px; box-shadow:var(--shadow-lg);
17632
18527
  display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
17633
18528
  @keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
18529
+ .toast.error { background:var(--danger); color:#fff; }
18530
+ .toast.leaving { animation:toast-out .16s ease-in forwards; pointer-events:none; }
18531
+ @keyframes toast-out { to { opacity:0; transform:translateX(-8px); } }
18532
+ .toast-msg { min-width:0; }
18533
+ .toast-act { margin-left:4px; padding:3px 9px; border-radius:6px; font-weight:600; color:inherit; background:color-mix(in srgb, var(--bg) 16%, transparent); white-space:nowrap; }
18534
+ .toast-act:hover { background:color-mix(in srgb, var(--bg) 28%, transparent); }
17634
18535
 
17635
18536
  /* misc */
17636
18537
  .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
@@ -17698,9 +18599,74 @@ var styles_default = ` :root {
17698
18599
  .kg-glow { opacity:.6 !important; }
17699
18600
  .kg-particle { opacity:0 !important; }
17700
18601
  }
17701
- .error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
18602
+ .error-bar { margin:12px 24px; padding:8px 10px 8px 12px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px;
18603
+ display:flex; align-items:center; gap:8px; animation:mo-in .13s ease-out; }
18604
+ .error-bar .error-msg { flex:1; min-width:0; overflow-wrap:anywhere; }
18605
+ .error-bar .error-x { display:grid; place-items:center; width:22px; height:22px; border-radius:50%; color:inherit; flex:none; }
18606
+ .error-bar .error-x:hover { background:color-mix(in srgb, var(--danger) 14%, transparent); }
18607
+ .sb-empty { color:var(--muted); gap:6px; width:100%; text-align:left; }
18608
+ .sb-empty:hover { color:var(--fg); }
18609
+ .tbl-empty { padding:36px 20px 28px; }
18610
+ .set-empty { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:6px 0; }
18611
+ .blob-mgr-empty { display:flex; flex-direction:column; align-items:center; gap:12px; }
18612
+ .doc-skel { max-width:720px; margin:40px auto; padding:0 24px; }
18613
+
18614
+ /* share / publish modal (share-modal.tsx) */
18615
+ .mhshare-body { display:flex; flex-direction:column; gap:10px; }
18616
+ .mhshare-section { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; margin-top:6px; }
18617
+ .mhshare-field { display:flex; align-items:center; gap:12px; }
18618
+ .mhshare-field > span { width:64px; color:var(--muted); flex:none; font-size:var(--fs-ui); }
18619
+ .mhshare-field select, .mhshare-field input { flex:1; padding:7px 9px; border:1px solid var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); color:inherit; min-width:0; font:inherit; }
18620
+ .mhshare-field select:focus, .mhshare-field input:focus { border-color:var(--accent); outline:none; }
18621
+ .mhshare-field .row-select { flex:1; justify-content:space-between; border:1px solid var(--line-strong); background:var(--surface); }
18622
+ .mhshare-foot { display:flex; justify-content:flex-end; gap:8px; margin:4px 0 2px; }
18623
+ .mhshare-foot button, .mhshare-result button, .mhshare-result a, .mhshare-li-actions button, .mhshare-li-actions a, .mhshare-guide-actions button {
18624
+ display:inline-flex; align-items:center; gap:6px; padding:5px 11px; border:1px solid var(--line-strong); border-radius:var(--radius-sm);
18625
+ background:var(--surface); color:inherit; font-size:var(--fs-sm); font-weight:500; text-decoration:none; cursor:pointer; transition:all .12s; }
18626
+ .mhshare-foot button:hover, .mhshare-result button:hover, .mhshare-result a:hover, .mhshare-li-actions button:hover, .mhshare-li-actions a:hover, .mhshare-guide-actions button:hover { background:var(--hover-2); }
18627
+ .mhshare-primary { background:var(--accent) !important; color:var(--accent-fg) !important; border-color:var(--accent) !important; }
18628
+ .mhshare-primary:hover { filter:brightness(1.05); }
18629
+ .mhshare-primary:disabled { opacity:.6; cursor:default; }
18630
+ .mhshare-note { color:var(--muted); font-size:var(--fs-sm); margin:2px 0; line-height:1.5; }
18631
+ .mhshare-err { color:var(--danger); font-size:var(--fs-sm); margin:0; }
18632
+ .mhshare-ok { color:var(--success); font-size:var(--fs-sm); margin:0; }
18633
+ .mhshare-warn { color:var(--fg-soft); font-size:var(--fs-xs); }
18634
+ .mhshare-result { display:flex; gap:6px; align-items:center; padding:8px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--surface); }
18635
+ .mhshare-result code { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:var(--fs-xs); font-family:var(--mono); }
18636
+ .mhshare-list { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px; }
18637
+ .mhshare-list li { border:1px solid var(--line); border-radius:var(--radius); padding:9px 11px; display:flex; flex-direction:column; gap:6px; }
18638
+ .mhshare-li-main { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
18639
+ .mhshare-badge { font-size:var(--fs-micro); background:var(--surface-2); border:1px solid var(--line); border-radius:999px; padding:1px 8px; }
18640
+ .mhshare-badge-pub { color:var(--accent); border-color:transparent; background:var(--accent-soft); }
18641
+ .mhshare-src { font-weight:600; overflow-wrap:anywhere; }
18642
+ .mhshare-meta { color:var(--muted); font-size:var(--fs-xs); }
18643
+ .mhshare-li-actions { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
18644
+ .mhshare-danger { color:var(--danger) !important; }
18645
+ .mhshare-form { display:flex; flex-direction:column; gap:10px; border:1px solid var(--line); border-radius:var(--radius); padding:12px; }
18646
+ .mhshare-addrow { display:flex; gap:8px; }
18647
+ .mhshare-addbtn { flex:1; display:flex; flex-direction:column; align-items:flex-start; gap:2px; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--radius); background:none; color:inherit; cursor:pointer; text-align:left; transition:border-color .12s, box-shadow .12s; }
18648
+ .mhshare-addbtn:hover { background:var(--hover); }
18649
+ .mhshare-addbtn.sel { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
18650
+ .mhshare-grants { display:flex; flex-direction:column; gap:6px; }
18651
+ .mhshare-grantlist { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:4px; }
18652
+ .mhshare-grantlist li { display:flex; align-items:center; gap:14px; padding:5px 8px; border:1px solid var(--line); border-radius:var(--radius-sm); }
18653
+ .mhshare-grantlist label { display:flex; align-items:center; gap:4px; font-size:var(--fs-sm); color:var(--muted); cursor:pointer; }
18654
+ .mhshare-grantdb { flex:1; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18655
+ .mhshare-adv { align-self:flex-start; background:none; border:0; color:var(--accent); font-size:var(--fs-xs); cursor:pointer; padding:0; }
18656
+ .mhshare-acard-t { font-weight:600; font-size:var(--fs-ui); }
18657
+ .mhshare-acard-d { color:var(--muted); font-size:var(--fs-xs); }
18658
+ .mhshare-hostsum { flex:1; display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); font-size:var(--fs-sm); }
18659
+ .mhshare-hostsum span { flex:1; }
18660
+ .mhshare-hostsum button { background:none; border:0; color:var(--accent); font-size:var(--fs-xs); cursor:pointer; padding:0; flex:none; }
18661
+ .mhshare-guide { border:1px solid var(--line-strong); border-left:3px solid var(--accent); border-radius:var(--radius-sm); padding:8px 11px; display:flex; flex-direction:column; gap:6px; }
18662
+ .mhshare-guide p { margin:0; font-size:var(--fs-sm); color:var(--fg); }
18663
+ .mhshare-guide-actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
18664
+ .mhshare-guide-link { border:0 !important; background:none !important; color:var(--accent) !important; padding:4px 0 !important; }
17702
18665
  .offline-bar { margin:12px 24px 0; padding:8px 14px; background:var(--surface-2); color:var(--fg-soft); border-radius:var(--radius-sm); font-size:12.5px; }
17703
18666
  .search-hit { margin:0 0 8px; padding:10px 14px; background:var(--surface); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
18667
+ .search-hit-head { display:flex; align-items:baseline; gap:6px; min-width:0; }
18668
+ .search-hit-head strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18669
+ .search-hit-head .muted { flex:none; }
17704
18670
  .search-hit:hover { background:var(--surface-2); }
17705
18671
  .search-hit mark { background:transparent; color:var(--accent); font-weight:600; }
17706
18672
  .view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
@@ -17901,6 +18867,7 @@ var styles_default = ` :root {
17901
18867
  html:has(body.mobile), body.mobile { background:var(--sidebar); }
17902
18868
  html:has(body.mobile-content), body.mobile-content { background:var(--bg); }
17903
18869
  .sb-head .iconbtn { display:none; } /* no collapse on mobile */
18870
+ .sb-head .sync-ind { display:inline-flex; height:32px; } /* .brand's flex:1 already pushes it right */
17904
18871
  .sb-resizer { display:none; }
17905
18872
  .topbar .hamburger { display:grid; } /* serves as the back button */
17906
18873
  /* site config topbar: icon-only publish button so the crumb keeps its last segment */
@@ -17922,17 +18889,15 @@ var styles_default = ` :root {
17922
18889
  .navitem { padding:9px 8px; font-size:16px; }
17923
18890
  .navitem .emoji { font-size:18px; }
17924
18891
  .navitem .tw { width:26px; height:26px; }
17925
- .sb-search kbd, .pop .item kbd { display:none; } /* no hardware keyboard, no shortcut hint */
18892
+ .sb-search .kbd, :is(.pop,.palette) .item .kbd { display:none; } /* no hardware keyboard, no shortcut hint */
17926
18893
  /* Search box is hidden until the header search button reveals it; it then
17927
18894
  slides+fades down in. Desktop base (.sb-search{display:flex}) is untouched,
17928
18895
  so search stays always-on there. reduced-motion is handled globally. */
17929
18896
  .sb-search { display:none; }
17930
- .sb-search.open { display:flex; animation:search-reveal .22s cubic-bezier(.32,.72,0,1); }
17931
- @keyframes search-reveal { from { opacity:0; transform:translateY(-8px); } }
17932
18897
  .btn { font-size:15px; }
17933
18898
  .crumb { font-size:16px; }
17934
- .pop .item { font-size:16px; }
17935
- .pop .item .t { font-size:16px; }
18899
+ :is(.pop,.palette) .item { font-size:16px; }
18900
+ :is(.pop,.palette) .item .t { font-size:16px; }
17936
18901
 
17937
18902
  /* Document: bigger body text, tighter side padding for narrow screens. */
17938
18903
  .doc { padding:24px 18px calc(36vh + env(safe-area-inset-bottom)); }
@@ -17983,11 +18948,24 @@ var styles_default = ` :root {
17983
18948
  border-radius:var(--radius-xl) var(--radius-xl) 0 0;
17984
18949
  padding-bottom:env(safe-area-inset-bottom);
17985
18950
  animation:sheet-up .22s cubic-bezier(.32,.72,0,1); }
18951
+ .modal-scrim.closing .modal { animation:sheet-out .16s ease-in forwards; }
18952
+ .modal-scrim.closing .modal.palette { animation:sheet-down-out .16s ease-in forwards; }
18953
+ .modal.palette { align-self:flex-start; margin-top:0; max-height:80vh; max-height:80dvh;
18954
+ padding:env(safe-area-inset-top) 8px 0; border-radius:0 0 var(--radius-xl) var(--radius-xl);
18955
+ animation:sheet-down .22s cubic-bezier(.32,.72,0,1); }
18956
+ :is(.pop,.palette) .item.sub-r .meta { display:block; }
18957
+ :is(.pop,.palette) .item.sub-r .d { max-width:none; }
18958
+ :is(.pop,.palette) .item .ret, .pal-foot .keys, .pal-head .kbd, .modal-hint { display:none; }
18959
+ .pal-foot .hint { margin-left:0; }
18960
+ .pal-close { display:grid; }
17986
18961
  .toasts { left:12px; right:12px; bottom:calc(12px + env(safe-area-inset-bottom)); }
17987
18962
  .toast { justify-content:center; }
17988
18963
  .selbar { bottom:calc(16px + env(safe-area-inset-bottom)); max-width:calc(100vw - 24px); }
17989
18964
  }
17990
18965
  @keyframes sheet-up { from { transform:translateY(100%); } }
18966
+ @keyframes sheet-down { from { transform:translateY(-100%); } }
18967
+ @keyframes sheet-out { to { transform:translateY(100%); } }
18968
+ @keyframes sheet-down-out { to { transform:translateY(-100%); } }
17991
18969
  /* In-flow stand-in for the old two-pane slide: only the entering view
17992
18970
  animates (the other is display:none, document-scroll layout above). */
17993
18971
  @keyframes page-in { from { transform:translateX(28px); opacity:0; } }
@@ -18004,7 +18982,7 @@ var styles_default = ` :root {
18004
18982
  .sb-tabs .add { width:40px; height:40px; display:grid; place-items:center; padding:0; }
18005
18983
  .iconbtn { width:40px; height:40px; }
18006
18984
  .btn { padding:9px 14px; min-height:40px; }
18007
- .pop .item { padding:11px 10px; }
18985
+ :is(.pop,.palette) .item { padding:11px 10px; }
18008
18986
  /* Column/sidebar resize handles: 7px is a mouse affordance, not a finger's. */
18009
18987
  .col-resizer, .doc-col-resizer, .sb-resizer { width:16px; right:-8px; }
18010
18988
  /* cell-rectangle corner handle: big enough to grab with a finger (it is the
@@ -18078,6 +19056,8 @@ var styles_default = ` :root {
18078
19056
  .qn-body { flex:1; overflow:auto; scrollbar-gutter:stable; scrollbar-width:thin; -webkit-app-region:no-drag; }
18079
19057
  .qn-body .doc { max-width:none; margin:0; padding:14px 1px 30vh 12px; }
18080
19058
  .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
19059
+ .qn-body .doc-title { overflow-wrap:anywhere; }
19060
+ .qn-body .doc-title:not(:focus) { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18081
19061
  .qn-body .doc-meta { display:none; }
18082
19062
  /* The CM6 hover gutter never fits the compact window: quick notes offer no
18083
19063
  block insert/drag handles (Enter / slash commands cover insertion). */
@@ -18098,6 +19078,7 @@ var styles_default = ` :root {
18098
19078
  share .pop/.item/.lico with the main app; scope under the quick window
18099
19079
  body classes so the main app keeps its roomier density). */
18100
19080
  body.quicknote .pop, body.quickboard .pop { padding:5px; border-radius:9px; }
19081
+ body.quicknote .menu-layer .pop, body.quickboard .menu-layer .pop { max-width:50vw; }
18101
19082
  body.quicknote .pop .lbl, body.quickboard .pop .lbl { padding:4px 8px 2px; font-size:10px; }
18102
19083
  body.quicknote .pop .item, body.quickboard .pop .item { padding:5px 9px; gap:8px; font-size:var(--fs-sm); }
18103
19084
  body.quicknote .pop .item .t, body.quickboard .pop .item .t { font-size:var(--fs-sm); }
@@ -18332,17 +19313,7 @@ var styles_default = ` :root {
18332
19313
  .switch.on .switch-knob { transform:translateX(16px); }
18333
19314
  .ico.spin { animation:save-spin .8s linear infinite; }
18334
19315
 
18335
- /* clear-policy segmented control (redundancy any/all) */
18336
19316
  .seg { display:flex; gap:8px; }
18337
- .seg-opt { flex:1 1 0; display:flex; flex-direction:column; gap:2px; align-items:flex-start;
18338
- text-align:left; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--radius);
18339
- background:var(--surface); cursor:pointer; transition:border-color .14s, background .14s, box-shadow .14s; }
18340
- .seg-opt:hover { border-color:var(--muted); }
18341
- .seg-opt.on { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset; }
18342
- .seg-opt:disabled { opacity:.6; cursor:default; }
18343
- .seg-opt-t { font-size:var(--fs-sm); font-weight:600; color:var(--fg); }
18344
- .seg-opt-s { font-size:var(--fs-xs); color:var(--muted); }
18345
- .seg-opt.on .seg-opt-s { color:var(--accent); }
18346
19317
 
18347
19318
  /* cache-usage ring hero (settings/cache-ring.tsx CacheRingHero): legend
18348
19319
  column left, 152px three-segment ring right. Arcs animate 0 \u2192 share on
@@ -18358,6 +19329,9 @@ var styles_default = ` :root {
18358
19329
  .blob-dot.keep { background:var(--line-strong); }
18359
19330
  .blob-dot.pin { background:var(--muted); opacity:.6; }
18360
19331
  .blob-total { margin:13px 0 14px; font-size:var(--fs-xs); color:var(--muted); }
19332
+ /* a status sentence in the action row (never a disabled button) */
19333
+ .blob-actions .blob-status { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--muted); }
19334
+ .blob-actions .blob-status .ico { width:14px; height:14px; color:var(--success); }
18361
19335
 
18362
19336
  .blob-ring { position:relative; width:152px; height:152px; flex:none; }
18363
19337
  .blob-ring-svg { width:100%; height:100%; display:block; }
@@ -18392,7 +19366,7 @@ var styles_default = ` :root {
18392
19366
  }
18393
19367
 
18394
19368
  @media (prefers-reduced-motion: reduce) {
18395
- .switch, .switch-knob, .ico.spin, .blob-seg, .blob-ring-track { transition:none; animation:none; }
19369
+ .switch, .switch-knob, .ico.spin, .sync-ring, .blob-seg, .blob-ring-track { transition:none; animation:none; }
18396
19370
  }
18397
19371
 
18398
19372
  /* Blob manager popup (Settings storage \u2192 Blob \u7BA1\u7406): a sortable/filterable list
@@ -18502,7 +19476,7 @@ var FMT_PROVIDERS = [
18502
19476
  init_package();
18503
19477
  var RUNNING_FROM_SOURCE = import.meta.url.includes("/src/webui/");
18504
19478
  var HTML2 = `<!doctype html>
18505
- <html lang="zh">
19479
+ <html lang="zh-CN">
18506
19480
  <head>
18507
19481
  <meta charset="utf-8" />
18508
19482
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
@@ -18526,6 +19500,9 @@ var HTML2 = `<!doctype html>
18526
19500
  alone, so the dark palette exists exactly once \u2014 see styles.css. Dark also
18527
19501
  pre-tints the status bar (hex mirrors --bg) so the first frame isn't white. -->
18528
19502
  <script>try{var t=localStorage.getItem('mh-theme'),d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.dataset.resolved=d?'dark':'light';if(d)document.getElementById('theme-color-meta').content='#1a1a1c'}catch(e){}</script>
19503
+ <!-- Same for the UI language: mirror src/webui/i18n/locale.ts so <html lang>
19504
+ is right before the bundle runs. -->
19505
+ <script>try{var l=localStorage.getItem('mh-lang');if(l!=='zh-CN'&&l!=='en'){var ls=navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language];l='en';for(var i=0;i<ls.length;i++){var g=ls[i]||'';if(/^zh\\b/i.test(g)){l='zh-CN';break}if(/^en\\b/i.test(g))break}}document.documentElement.lang=l}catch(e){}</script>
18529
19506
  <link rel="manifest" href="/manifest.webmanifest">
18530
19507
  <link rel="apple-touch-icon" href="/icons/icon-180.png">
18531
19508
  <link rel="stylesheet" href="/webui.css">
@@ -18776,6 +19753,358 @@ async function serveWebui(req) {
18776
19753
  }
18777
19754
  // src/webui/server/routes.ts
18778
19755
  init_zod();
19756
+
19757
+ // src/core/integrity.ts
19758
+ init_crdt();
19759
+ init_properties();
19760
+ init_hlc();
19761
+ init_node();
19762
+ init_history_cache();
19763
+ var REF_RULES = [
19764
+ { srcTable: "properties", fk: "database_id", tgtTable: "databases", fix: "tombstone-src" },
19765
+ { srcTable: "records", fk: "database_id", tgtTable: "databases", fix: "tombstone-src" },
19766
+ { srcTable: "documents", fk: "database_id", tgtTable: "databases", fix: "null-fk" },
19767
+ { srcTable: "documents", fk: "parent_id", tgtTable: "documents", fix: "null-fk" },
19768
+ { srcTable: "doc_blocks", fk: "doc_id", tgtTable: "documents", fix: "tombstone-src" },
19769
+ { srcTable: "site_files", fk: "site_id", tgtTable: "sites", fix: "tombstone-src" }
19770
+ ];
19771
+ function brokenRefRows(db, r) {
19772
+ return db.query(`SELECT s.id AS id, s.${r.fk} AS ref FROM ${r.srcTable} s
19773
+ JOIN ${r.tgtTable} t ON t.id = s.${r.fk} AND t.__deleted = 1
19774
+ WHERE s.__deleted = 0 AND s.${r.fk} IS NOT NULL
19775
+ ORDER BY s.id`).all();
19776
+ }
19777
+ function detectBrokenRefs(db) {
19778
+ const out = [];
19779
+ for (const r of REF_RULES) {
19780
+ for (const row of brokenRefRows(db, r)) {
19781
+ out.push({
19782
+ category: "broken_ref",
19783
+ entity: r.srcTable,
19784
+ id: row.id,
19785
+ detail: `${r.fk} \u2192 deleted ${r.tgtTable} ${row.ref} (${r.fix === "tombstone-src" ? "tombstone" : `clear ${r.fk}`})`,
19786
+ fixable: true
19787
+ });
19788
+ }
19789
+ }
19790
+ return out;
19791
+ }
19792
+ function repairBrokenRefs(db) {
19793
+ let n = 0;
19794
+ for (const r of REF_RULES) {
19795
+ for (const row of brokenRefRows(db, r)) {
19796
+ if (r.fix === "tombstone-src")
19797
+ emit(db, r.srcTable, row.id, "__deleted", 1);
19798
+ else
19799
+ emit(db, r.srcTable, row.id, r.fk, null);
19800
+ n++;
19801
+ }
19802
+ }
19803
+ return n;
19804
+ }
19805
+ function orphanCellRows(db) {
19806
+ return db.query(`SELECT r.id AS rec, j.key AS prop
19807
+ FROM records r, json_each(coalesce(r.data, '{}')) j
19808
+ JOIN properties p ON p.id = j.key AND p.__deleted = 1
19809
+ WHERE r.__deleted = 0
19810
+ ORDER BY r.id, j.key`).all();
19811
+ }
19812
+ function detectOrphanCells(db) {
19813
+ return orphanCellRows(db).map((row) => ({
19814
+ category: "orphan_cell",
19815
+ entity: "records",
19816
+ id: row.rec,
19817
+ detail: `cell for deleted property ${row.prop}`,
19818
+ fixable: true
19819
+ }));
19820
+ }
19821
+ function repairOrphanCells(db) {
19822
+ const rows = orphanCellRows(db);
19823
+ for (const row of rows)
19824
+ emit(db, "records", row.rec, row.prop, undefined);
19825
+ return rows.length;
19826
+ }
19827
+ function deadCellRefRows(db) {
19828
+ const props = db.query("SELECT id, type FROM properties WHERE __deleted = 0 AND type IN ('relation', 'doc') ORDER BY id").all();
19829
+ const out = [];
19830
+ for (const p of props) {
19831
+ const tgtTable = p.type === "relation" ? "records" : "documents";
19832
+ const pid = p.id.replace(/'/g, "''");
19833
+ const rows = db.query(`SELECT r.id AS rec, j.value AS dead
19834
+ FROM records r, json_each(r.data -> '${pid}') j
19835
+ JOIN ${tgtTable} t ON t.id = j.value AND t.__deleted = 1
19836
+ WHERE r.__deleted = 0 AND json_type(r.data -> '${pid}') = 'array'
19837
+ ORDER BY r.id, j.value`).all();
19838
+ let cur = null;
19839
+ for (const row of rows) {
19840
+ if (!cur || cur.rec !== row.rec) {
19841
+ cur = { rec: row.rec, prop: p.id, tgtTable, dead: [] };
19842
+ out.push(cur);
19843
+ }
19844
+ cur.dead.push(row.dead);
19845
+ }
19846
+ }
19847
+ return out;
19848
+ }
19849
+ function detectDeadCellRefs(db) {
19850
+ return deadCellRefRows(db).map((g) => ({
19851
+ category: "dead_cell_ref",
19852
+ entity: "records",
19853
+ id: g.rec,
19854
+ detail: `cell ${g.prop} references deleted ${g.tgtTable === "records" ? "record" : "document"}: ${g.dead.join(", ")}`,
19855
+ fixable: true
19856
+ }));
19857
+ }
19858
+ function repairDeadCellRefs(db) {
19859
+ const groups = deadCellRefRows(db);
19860
+ for (const g of groups) {
19861
+ const row = db.query("SELECT data FROM records WHERE id = ?").get(g.rec);
19862
+ if (!row)
19863
+ continue;
19864
+ const data = JSON.parse(row.data || "{}");
19865
+ const arr = data[g.prop];
19866
+ if (!Array.isArray(arr))
19867
+ continue;
19868
+ const deadSet = new Set(g.dead);
19869
+ emit(db, "records", g.rec, g.prop, arr.filter((v) => !deadSet.has(String(v))));
19870
+ }
19871
+ return groups.length;
19872
+ }
19873
+ function dupPathLosers(db) {
19874
+ const rows = db.query(`SELECT id, site_id, path FROM site_files
19875
+ WHERE __deleted = 0 AND site_id IS NOT NULL AND path IS NOT NULL
19876
+ ORDER BY site_id, path, created_hlc, id`).all();
19877
+ const losers = [];
19878
+ let group = null;
19879
+ for (const r of rows) {
19880
+ const key = `${r.site_id}\x00${r.path}`;
19881
+ if (key === group)
19882
+ losers.push({ id: r.id });
19883
+ else
19884
+ group = key;
19885
+ }
19886
+ return losers;
19887
+ }
19888
+ function detectDupPaths(db) {
19889
+ return dupPathLosers(db).map((row) => ({
19890
+ category: "dup_path",
19891
+ entity: "site_files",
19892
+ id: row.id,
19893
+ detail: "redundant duplicate (site_id, path); loser tombstoned",
19894
+ fixable: true
19895
+ }));
19896
+ }
19897
+ function repairDupPaths(db) {
19898
+ const losers = dupPathLosers(db);
19899
+ for (const row of losers)
19900
+ emit(db, "site_files", row.id, "__deleted", 1);
19901
+ return losers.length;
19902
+ }
19903
+ function detectParentCycles(db) {
19904
+ const rows = db.query(`SELECT d.id AS id, d.parent_id AS parent, d.created_hlc AS hlc
19905
+ FROM documents d
19906
+ WHERE d.__deleted = 0 AND d.parent_id IS NOT NULL
19907
+ AND EXISTS (SELECT 1 FROM documents p WHERE p.id = d.parent_id AND p.__deleted = 0)
19908
+ ORDER BY d.id`).all();
19909
+ const parent = new Map(rows.map((r) => [r.id, r.parent]));
19910
+ const hlc = new Map(rows.map((r) => [r.id, r.hlc]));
19911
+ const seen = new Set;
19912
+ const cycles = [];
19913
+ for (const start of parent.keys()) {
19914
+ if (seen.has(start))
19915
+ continue;
19916
+ const path = [];
19917
+ const pos = new Map;
19918
+ let cur = start;
19919
+ while (cur != null && parent.has(cur) && !seen.has(cur)) {
19920
+ if (pos.has(cur)) {
19921
+ cycles.push(path.slice(pos.get(cur)));
19922
+ break;
19923
+ }
19924
+ pos.set(cur, path.length);
19925
+ path.push(cur);
19926
+ cur = parent.get(cur);
19927
+ }
19928
+ for (const n of path)
19929
+ seen.add(n);
19930
+ }
19931
+ return { cycles, hlc };
19932
+ }
19933
+ function geHlcId(hlc, a, b) {
19934
+ const ha = hlc.get(a) ?? "";
19935
+ const hb = hlc.get(b) ?? "";
19936
+ if (ha !== hb)
19937
+ return ha > hb;
19938
+ return a >= b;
19939
+ }
19940
+ function detectCycleIssues(db) {
19941
+ const { cycles } = detectParentCycles(db);
19942
+ return cycles.map((cyc) => ({
19943
+ category: "parent_cycle",
19944
+ entity: "documents",
19945
+ id: [...cyc].sort()[0],
19946
+ detail: `parent cycle: ${[...cyc].sort().join(" \u2192 ")}`,
19947
+ fixable: true
19948
+ }));
19949
+ }
19950
+ function repairParentCycles(db) {
19951
+ const { cycles, hlc } = detectParentCycles(db);
19952
+ for (const cyc of cycles) {
19953
+ const brk = cyc.reduce((a, b) => geHlcId(hlc, a, b) ? a : b);
19954
+ emit(db, "documents", brk, "parent_id", null);
19955
+ }
19956
+ return cycles.length;
19957
+ }
19958
+ function detectDupNames(db) {
19959
+ const out = [];
19960
+ const props = db.query(`SELECT database_id AS db, lower(name) AS n, count(*) AS c, group_concat(id) AS ids
19961
+ FROM properties
19962
+ WHERE __deleted = 0 AND name IS NOT NULL AND database_id IS NOT NULL
19963
+ GROUP BY database_id, lower(name) HAVING c > 1
19964
+ ORDER BY database_id, n`).all();
19965
+ for (const r of props)
19966
+ out.push({
19967
+ category: "dup_name",
19968
+ entity: "properties",
19969
+ id: r.ids.split(",").sort()[0],
19970
+ detail: `${r.c} properties named "${r.n}" in database ${r.db}: ${r.ids}`,
19971
+ fixable: false
19972
+ });
19973
+ const dbs = db.query(`SELECT lower(name) AS n, count(*) AS c, group_concat(id) AS ids
19974
+ FROM databases WHERE __deleted = 0 AND name IS NOT NULL
19975
+ GROUP BY lower(name) HAVING c > 1 ORDER BY n`).all();
19976
+ for (const r of dbs)
19977
+ out.push({
19978
+ category: "dup_name",
19979
+ entity: "databases",
19980
+ id: r.ids.split(",").sort()[0],
19981
+ detail: `${r.c} databases named "${r.n}": ${r.ids}`,
19982
+ fixable: false
19983
+ });
19984
+ return out;
19985
+ }
19986
+ function configIssue(type, config) {
19987
+ if (!PROP_TYPES.has(type))
19988
+ return `unknown property type: ${type}`;
19989
+ if (type === "select" || type === "multi_select") {
19990
+ const opts = config?.options;
19991
+ if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
19992
+ return `${type} missing config.options: string[]`;
19993
+ }
19994
+ if (type === "relation" && typeof config?.database !== "string")
19995
+ return "relation missing config.database";
19996
+ return null;
19997
+ }
19998
+ function detectBadConfig(db) {
19999
+ const rows = db.query("SELECT id, type, config FROM properties WHERE __deleted = 0 ORDER BY id").all();
20000
+ const out = [];
20001
+ for (const r of rows) {
20002
+ let config = null;
20003
+ try {
20004
+ config = r.config ? JSON.parse(r.config) : null;
20005
+ } catch {
20006
+ out.push({ category: "bad_config", entity: "properties", id: r.id, detail: "config is not valid JSON", fixable: false });
20007
+ continue;
20008
+ }
20009
+ const problem = configIssue(r.type, config);
20010
+ if (problem)
20011
+ out.push({ category: "bad_config", entity: "properties", id: r.id, detail: problem, fixable: false });
20012
+ }
20013
+ return out;
20014
+ }
20015
+ function detectClockSkew(db, now) {
20016
+ const rows = db.query("SELECT node_id, COUNT(*) AS n FROM crdt_changes WHERE hlc > ? GROUP BY node_id").all(hlcSkewBound(now));
20017
+ const issues = rows.map((r) => ({
20018
+ category: "clock_skew",
20019
+ entity: "nodes",
20020
+ id: r.node_id,
20021
+ detail: `${r.n} change(s) stamped more than 5 min in the future \u2014 run \`mh repair --clock\``,
20022
+ fixable: false
20023
+ }));
20024
+ const local = persistedHlc(db);
20025
+ if (local && local.millis > now + HLC_MAX_SKEW_MS)
20026
+ issues.push({
20027
+ category: "clock_skew",
20028
+ entity: "meta",
20029
+ id: "hlc",
20030
+ detail: `local logical clock is ${Math.ceil((local.millis - now) / 60000)} min ahead of wall time \u2014 writes refuse until \`mh repair --clock\``,
20031
+ fixable: false
20032
+ });
20033
+ return issues;
20034
+ }
20035
+ function repairClock(db, now = Date.now()) {
20036
+ return writeTx(db, () => {
20037
+ const rows = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes WHERE hlc > ? ORDER BY hlc, seq`).all(hlcSkewBound(now));
20038
+ const byNode = {};
20039
+ const local = persistedHlc(db);
20040
+ const clockReset = rows.length > 0 || local != null && local.millis > now + HLC_MAX_SKEW_MS;
20041
+ if (clockReset)
20042
+ resetHlc(db, getNodeId(db), now);
20043
+ if (rows.length === 0)
20044
+ return { restamped: 0, byNode, clockReset };
20045
+ const touched = new Map;
20046
+ for (const r of rows) {
20047
+ db.query("DELETE FROM crdt_changes WHERE seq = ?").run(r.seq);
20048
+ const { seq: _seq, ...c } = r;
20049
+ const change = { ...c, hlc: nextHlc(db, r.node_id, now) };
20050
+ applyChange(db, change);
20051
+ touched.set(`${c.dataset}\x00${c.row_id}\x00${c.col}`, change);
20052
+ byNode[r.node_id] = (byNode[r.node_id] ?? 0) + 1;
20053
+ }
20054
+ for (const c of touched.values()) {
20055
+ const winner = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes WHERE dataset = ? AND row_id = ? AND col = ? ORDER BY hlc DESC LIMIT 1`).get(c.dataset, c.row_id, c.col);
20056
+ if (winner)
20057
+ applyChange(db, winner);
20058
+ }
20059
+ invalidateHistory(db);
20060
+ return { restamped: rows.length, byNode, clockReset };
20061
+ });
20062
+ }
20063
+ function validateHub(db, opts = {}) {
20064
+ const issues = [
20065
+ ...detectBrokenRefs(db),
20066
+ ...detectOrphanCells(db),
20067
+ ...detectDeadCellRefs(db),
20068
+ ...detectDupPaths(db),
20069
+ ...detectCycleIssues(db),
20070
+ ...detectDupNames(db),
20071
+ ...detectBadConfig(db),
20072
+ ...detectClockSkew(db, opts.now ?? Date.now())
20073
+ ];
20074
+ const counts = {};
20075
+ for (const i of issues)
20076
+ counts[i.category] = (counts[i.category] ?? 0) + 1;
20077
+ return { ok: issues.length === 0, total: issues.length, counts, issues };
20078
+ }
20079
+ function repairPass(db) {
20080
+ const fixed = {};
20081
+ const bump = (cat, n) => {
20082
+ if (n)
20083
+ fixed[cat] = (fixed[cat] ?? 0) + n;
20084
+ };
20085
+ bump("broken_ref", repairBrokenRefs(db));
20086
+ bump("orphan_cell", repairOrphanCells(db));
20087
+ bump("dead_cell_ref", repairDeadCellRefs(db));
20088
+ bump("dup_path", repairDupPaths(db));
20089
+ bump("parent_cycle", repairParentCycles(db));
20090
+ const applied = Object.values(fixed).reduce((a, b) => a + b, 0);
20091
+ return { applied, fixed };
20092
+ }
20093
+ var repairHub = grouped(function repairHub(db) {
20094
+ const fixed = {};
20095
+ let applied = 0;
20096
+ for (let i = 0;i < 20; i++) {
20097
+ const pass = repairPass(db);
20098
+ if (pass.applied === 0)
20099
+ break;
20100
+ applied += pass.applied;
20101
+ for (const [cat, n] of Object.entries(pass.fixed))
20102
+ fixed[cat] = (fixed[cat] ?? 0) + n;
20103
+ }
20104
+ return { applied, fixed, remaining: validateHub(db) };
20105
+ }, "repair");
20106
+
20107
+ // src/webui/server/routes.ts
18779
20108
  init_errors();
18780
20109
  init_routes();
18781
20110
  init_databases();
@@ -18788,6 +20117,7 @@ init_crdt();
18788
20117
  init_documents();
18789
20118
  init_properties();
18790
20119
  init_errors();
20120
+ init_history_cache();
18791
20121
  var REVISION_GAP_MS = 1500;
18792
20122
  function hlcMillis(hlc) {
18793
20123
  return Number(hlc.slice(0, 15));
@@ -18845,16 +20175,23 @@ function rowChanges(db, dataset, rowId) {
18845
20175
  function everBlockIds(db, docId) {
18846
20176
  return db.query("SELECT DISTINCT row_id FROM crdt_changes WHERE dataset = 'doc_blocks' AND col = 'doc_id' AND value = ?").all(JSON.stringify(docId)).map((r) => r.row_id);
18847
20177
  }
18848
- function docChanges(db, docId) {
20178
+ function docHistory(db, docId) {
20179
+ const cached = getDocHistory(db, docId);
20180
+ if (cached)
20181
+ return cached;
18849
20182
  const blocks = everBlockIds(db, docId);
18850
20183
  const placeholders = blocks.map(() => "?").join(",");
18851
20184
  const blockClause = blocks.length ? ` OR (dataset = 'doc_blocks' AND row_id IN (${placeholders}))` : "";
18852
- return db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes
20185
+ const changes = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes
18853
20186
  WHERE (dataset = 'documents' AND row_id = ?)${blockClause}
18854
20187
  ORDER BY hlc`).all(docId, ...blocks);
20188
+ return setDocHistory(db, docId, changes, blocks);
18855
20189
  }
18856
20190
  function listDocumentRevisions(db, id) {
18857
- const changes = docChanges(db, id);
20191
+ const entry = docHistory(db, id);
20192
+ if (entry.revisions)
20193
+ return entry.revisions.slice();
20194
+ const changes = entry.changes;
18858
20195
  if (!changes.length)
18859
20196
  throw new MhError("not_found", `no such document: ${id}`);
18860
20197
  const reg = new Map;
@@ -18909,12 +20246,17 @@ function listDocumentRevisions(db, id) {
18909
20246
  blocks_deleted: deletedBlocks.size
18910
20247
  });
18911
20248
  }
18912
- return out.reverse();
20249
+ entry.revisions = out.reverse();
20250
+ return entry.revisions.slice();
18913
20251
  }
18914
20252
  function documentAtVersion(db, id, at) {
18915
20253
  if (!at)
18916
20254
  throw new MhError("invalid_input", "missing version cutoff");
18917
- const changes = docChanges(db, id).filter((c) => c.hlc <= at);
20255
+ const entry = docHistory(db, id);
20256
+ const hit = entry.states.get(at);
20257
+ if (hit)
20258
+ return { ...hit };
20259
+ const changes = entry.changes.filter((c) => c.hlc <= at);
18918
20260
  if (!changes.length)
18919
20261
  throw new MhError("not_found", `no version of document ${id} at or before ${at}`);
18920
20262
  const docRegs = registersAt(changes.filter((c) => c.dataset === "documents"), at);
@@ -18960,13 +20302,15 @@ function documentAtVersion(db, id, at) {
18960
20302
  return a.id < b.id ? -1 : 1;
18961
20303
  });
18962
20304
  const body = byBlock.size ? serializeDocBlocks(blocks) : parse("body") ?? "";
18963
- return {
20305
+ const state = {
18964
20306
  id,
18965
20307
  title: parse("title") ?? "",
18966
20308
  body,
18967
20309
  deleted: docRegs.get("__deleted") ? flagSet(docRegs.get("__deleted").value) : false,
18968
20310
  version: changes[changes.length - 1].hlc
18969
20311
  };
20312
+ rememberDocState(entry, at, state);
20313
+ return { ...state };
18970
20314
  }
18971
20315
  var revertDocument = grouped(function revertDocument(db, id, to, opts = {}) {
18972
20316
  let cur = getDocument(db, id);
@@ -19270,15 +20614,49 @@ function recordFieldHistory(db, id, propId) {
19270
20614
  // src/core/audit.ts
19271
20615
  init_crdt();
19272
20616
  init_errors();
20617
+
20618
+ // src/core/tables.ts
20619
+ var TABLES = {
20620
+ databases: { tier: "content", synced: true },
20621
+ properties: { tier: "content", synced: true },
20622
+ records: { tier: "content", synced: true },
20623
+ documents: { tier: "content", synced: true },
20624
+ doc_blocks: { tier: "content", synced: true },
20625
+ sites: { tier: "content", synced: true },
20626
+ site_files: { tier: "content", synced: true },
20627
+ site_channels: { tier: "system", synced: true },
20628
+ blob_policy: { tier: "system", synced: true },
20629
+ nodes: { tier: "system", synced: true },
20630
+ meta: { tier: "local", synced: false },
20631
+ crdt_changes: { tier: "local", synced: false },
20632
+ peers: { tier: "local", synced: false },
20633
+ storage_cursors: { tier: "local", synced: false },
20634
+ room_rows: { tier: "local", synced: false },
20635
+ peer_grants: { tier: "local", synced: false },
20636
+ pairing_codes: { tier: "local", synced: false },
20637
+ shares: { tier: "local", synced: false },
20638
+ site_channel_observations: { tier: "local", synced: false },
20639
+ blob_cache: { tier: "local", synced: false },
20640
+ drop_rejects: { tier: "local", synced: false },
20641
+ search_fts: { tier: "local", synced: false }
20642
+ };
20643
+ var names = (pred) => Object.entries(TABLES).filter(([, s]) => pred(s)).map(([n]) => n);
20644
+ var SYNCED_TABLES = names((s) => s.synced);
20645
+ var CONTENT_TABLES = names((s) => s.tier === "content");
20646
+ var SYSTEM_DATASETS = names((s) => s.tier === "system");
20647
+ var LOCAL_TABLES = names((s) => s.tier === "local");
20648
+
20649
+ // src/core/audit.ts
19273
20650
  function isMetaCol(dataset, col) {
19274
20651
  if (dataset === "records")
19275
20652
  return RECORD_META.has(col);
19276
20653
  return col === "created_hlc" || col === "__deleted";
19277
20654
  }
19278
20655
  function oplogExclude() {
20656
+ const excluded = [...OPLOG_ONLY_DATASETS, ...SYSTEM_DATASETS];
19279
20657
  return {
19280
- sql: [...OPLOG_ONLY_DATASETS].map(() => "dataset != ?"),
19281
- params: [...OPLOG_ONLY_DATASETS]
20658
+ sql: excluded.map(() => "dataset != ?"),
20659
+ params: excluded
19282
20660
  };
19283
20661
  }
19284
20662
  function changesOfTxn(db, txn, beforeHlc) {
@@ -19564,6 +20942,7 @@ var revertChangeGroup = grouped(function revertChangeGroup(db, txn) {
19564
20942
  }, "revert");
19565
20943
 
19566
20944
  // src/webui/server/routes.ts
20945
+ init_resolve();
19567
20946
  init_node();
19568
20947
  init_blobs();
19569
20948
  init_config();
@@ -19728,7 +21107,7 @@ async function createBucketShare(db, opts) {
19728
21107
  contentType: "application/octet-stream"
19729
21108
  });
19730
21109
  await mergeViewerCors(opts.config, opts.viewerOrigin);
19731
- return { manifestUrl, presignExp, keyB64: opts.password ? undefined : toB64(shareKey), saltB64, title };
21110
+ return { manifestUrl, presignExp, keyB64: opts.password ? undefined : toB64(shareKey), saltB64, title, meta };
19732
21111
  }
19733
21112
  async function renewBucketShare(db, config, slug, expiresSec = MAX_PRESIGN_SECONDS) {
19734
21113
  const meta = await readMeta2(config, slug);
@@ -19795,7 +21174,7 @@ async function represignBucketShare(config, slug, expiresSec = MAX_PRESIGN_SECON
19795
21174
  async function listBucketShares(config) {
19796
21175
  const client = storageClientFor(config);
19797
21176
  const root = sharesRoot(config.prefix);
19798
- const entries = await client.list(`${root}/`, undefined, "/").catch(() => []);
21177
+ const entries = await client.list(`${root}/`, undefined, "/");
19799
21178
  const slugs = entries.map((e) => e.key.slice(`${root}/`.length).replace(/\/$/, "")).filter((s) => s && !s.includes("/"));
19800
21179
  const out = [];
19801
21180
  for (const slug of slugs) {
@@ -19824,10 +21203,156 @@ function clampExpiry(sec) {
19824
21203
 
19825
21204
  // src/core/sync/share-actions.ts
19826
21205
  init_storage_s3_sign();
21206
+
21207
+ // src/core/sync/remote-shares-cache.ts
21208
+ init_peers();
21209
+ var caches2 = new WeakMap;
21210
+ var listeners = new Set;
21211
+ var REMOTE_SHARES_STALE_MS = 60000;
21212
+ var PEER_LIST_TIMEOUT_MS = 4000;
21213
+ function absoluteShareUrl(url, base) {
21214
+ try {
21215
+ return new URL(url, `${base.replace(/\/+$/, "")}/`).toString();
21216
+ } catch {
21217
+ return url;
21218
+ }
21219
+ }
21220
+ function bucketShareItem(bucket, m) {
21221
+ return {
21222
+ slug: m.slug,
21223
+ kind: m.kind,
21224
+ target_id: m.target_id,
21225
+ title: m.title || m.target_id,
21226
+ permission: m.permission,
21227
+ transport: "s3",
21228
+ source: `\u6876 ${bucket.label ?? bucket.url}`,
21229
+ sourceKind: "bucket",
21230
+ hosting: "s3",
21231
+ expiresAt: m.presign_exp,
21232
+ hasPassword: m.has_password,
21233
+ contentUpdatedAt: m.content_updated_at ?? m.created_at
21234
+ };
21235
+ }
21236
+ async function fetchPeerShares(url, token, targetId) {
21237
+ const u = `${url.replace(/\/+$/, "")}/api/shares${targetId ? `?target=${encodeURIComponent(targetId)}` : ""}`;
21238
+ const res = await Promise.race([
21239
+ fetch(u, { headers: { authorization: `Bearer ${token}` } }),
21240
+ new Promise((_r, rej) => setTimeout(() => rej(new Error("timeout")), PEER_LIST_TIMEOUT_MS))
21241
+ ]);
21242
+ if (!res.ok)
21243
+ return [];
21244
+ return (await res.json()).map((item) => ({
21245
+ ...item,
21246
+ ...item.url ? { url: absoluteShareUrl(item.url, url) } : {}
21247
+ }));
21248
+ }
21249
+ function remoteShareSources(db) {
21250
+ return listPeers(db).flatMap((p) => {
21251
+ if (p.kind === "s3" && p.config)
21252
+ return [{ url: p.url, kind: "bucket", peer: p }];
21253
+ if (p.kind === "http" && p.token)
21254
+ return [{ url: p.url, kind: "peer", peer: p }];
21255
+ return [];
21256
+ });
21257
+ }
21258
+ async function fetchSource(s) {
21259
+ if (s.kind === "bucket") {
21260
+ const config = JSON.parse(s.peer.config);
21261
+ return (await listBucketShares(config)).map((m) => bucketShareItem(s.peer, m));
21262
+ }
21263
+ return (await fetchPeerShares(s.url, s.peer.token)).map((it) => ({
21264
+ ...it,
21265
+ source: s.peer.label ?? s.url,
21266
+ sourceKind: "peer",
21267
+ sourceUrl: s.url
21268
+ }));
21269
+ }
21270
+ function cacheFor2(db) {
21271
+ let m = caches2.get(db);
21272
+ if (!m) {
21273
+ m = new Map;
21274
+ caches2.set(db, m);
21275
+ }
21276
+ return m;
21277
+ }
21278
+ function entryFor(db, url) {
21279
+ const m = cacheFor2(db);
21280
+ let e = m.get(url);
21281
+ if (!e) {
21282
+ e = { items: [], fetchedAt: 0, error: null, inflight: null };
21283
+ m.set(url, e);
21284
+ }
21285
+ return e;
21286
+ }
21287
+ function notify(db) {
21288
+ for (const fn of [...listeners])
21289
+ fn(db);
21290
+ }
21291
+ function refreshRemoteSource(db, s) {
21292
+ const e = entryFor(db, s.url);
21293
+ if (e.inflight)
21294
+ return e.inflight;
21295
+ e.inflight = fetchSource(s).then((items) => {
21296
+ const changed = JSON.stringify(items) !== JSON.stringify(e.items);
21297
+ e.items = items;
21298
+ e.error = null;
21299
+ e.fetchedAt = Date.now();
21300
+ if (changed)
21301
+ notify(db);
21302
+ }).catch((err) => {
21303
+ e.error = err instanceof Error ? err.message : String(err);
21304
+ e.fetchedAt = Date.now();
21305
+ }).finally(() => {
21306
+ e.inflight = null;
21307
+ });
21308
+ return e.inflight;
21309
+ }
21310
+ function cachedRemoteShares(db, targetId, opts = {}) {
21311
+ const now = opts.now ?? Date.now();
21312
+ const sources = remoteShareSources(db);
21313
+ const m = cacheFor2(db);
21314
+ const live = new Set(sources.map((s) => s.url));
21315
+ for (const key of [...m.keys()])
21316
+ if (!live.has(key))
21317
+ m.delete(key);
21318
+ const out = [];
21319
+ for (const s of sources) {
21320
+ const e = entryFor(db, s.url);
21321
+ if (opts.refresh !== false && !e.inflight && now - e.fetchedAt >= REMOTE_SHARES_STALE_MS)
21322
+ refreshRemoteSource(db, s);
21323
+ for (const it of e.items)
21324
+ if (!targetId || it.target_id === targetId)
21325
+ out.push(it);
21326
+ }
21327
+ return out;
21328
+ }
21329
+ function primeRemoteShare(db, url, item) {
21330
+ const e = entryFor(db, url);
21331
+ e.items = [...e.items.filter((it) => it.slug !== item.slug), item];
21332
+ }
21333
+ function dropRemoteShare(db, url, slug) {
21334
+ const e = caches2.get(db)?.get(url);
21335
+ if (e)
21336
+ e.items = e.items.filter((it) => it.slug !== slug);
21337
+ }
21338
+ function findCachedBucketShare(db, slug) {
21339
+ const m = caches2.get(db);
21340
+ if (!m)
21341
+ return null;
21342
+ for (const s of remoteShareSources(db)) {
21343
+ if (s.kind !== "bucket")
21344
+ continue;
21345
+ const item = m.get(s.url)?.items.find((it) => it.slug === slug);
21346
+ if (item)
21347
+ return { source: s, item };
21348
+ }
21349
+ return null;
21350
+ }
21351
+
21352
+ // src/core/sync/share-actions.ts
19827
21353
  init_site_channel_store();
19828
21354
  var DEFAULT_SHARE_VIEWER = "https://share.mh.tensorix.org";
19829
21355
  var DEFAULT_S3_EXPIRY_SEC = MAX_PRESIGN_SECONDS;
19830
- var PEER_LIST_TIMEOUT_MS = 4000;
19831
21356
  var PEER_SHARE_TIMEOUT_MS = 1e4;
19832
21357
  var CreatedShareWireSchema = object({
19833
21358
  slug: string2().min(1).max(128),
@@ -19924,13 +21449,6 @@ function bucketLink(base, manifestUrl, keyB64, saltB64) {
19924
21449
  const frag = `m=${encodeURIComponent(manifestUrl)}&` + (keyB64 ? `k=${keyB64}` : `s=${saltB64}`);
19925
21450
  return `${base}/#${frag}`;
19926
21451
  }
19927
- function absoluteShareUrl(url, base) {
19928
- try {
19929
- return new URL(url, `${base.replace(/\/+$/, "")}/`).toString();
19930
- } catch {
19931
- return url;
19932
- }
19933
- }
19934
21452
  function assertShareCombo(req) {
19935
21453
  const fail = (msg) => {
19936
21454
  throw new MhError("invalid_input", msg);
@@ -19968,6 +21486,7 @@ async function createShareAction(db, req) {
19968
21486
  expiresSec: req.expiresMs != null ? Math.floor(req.expiresMs / 1000) : DEFAULT_S3_EXPIRY_SEC,
19969
21487
  viewerOrigin: viewerOriginOf(base)
19970
21488
  });
21489
+ primeRemoteShare(db, bucketUrl, bucketShareItem({ url: bucketUrl, label }, out.meta));
19971
21490
  return {
19972
21491
  slug,
19973
21492
  kind: req.kind,
@@ -20157,28 +21676,21 @@ function listServerSharesLocal(db, targetId) {
20157
21676
  }
20158
21677
  return out;
20159
21678
  }
20160
- async function listSharesLocal(db, targetId) {
21679
+ async function listSharesLocal(db, targetId, opts = {}) {
20161
21680
  const out = listServerSharesLocal(db, targetId);
21681
+ if (opts.mode === "cached") {
21682
+ for (const it of cachedRemoteShares(db, targetId))
21683
+ if (it.sourceKind === "bucket")
21684
+ out.push(it);
21685
+ return out;
21686
+ }
20162
21687
  for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20163
21688
  const config = JSON.parse(p.config);
20164
21689
  const metas = await listBucketShares(config).catch(() => []);
20165
21690
  for (const m of metas) {
20166
21691
  if (targetId && m.target_id !== targetId)
20167
21692
  continue;
20168
- out.push({
20169
- slug: m.slug,
20170
- kind: m.kind,
20171
- target_id: m.target_id,
20172
- title: m.title || m.target_id,
20173
- permission: m.permission,
20174
- transport: "s3",
20175
- source: `\u6876 ${p.label ?? p.url}`,
20176
- sourceKind: "bucket",
20177
- hosting: "s3",
20178
- expiresAt: m.presign_exp,
20179
- hasPassword: m.has_password,
20180
- contentUpdatedAt: m.content_updated_at ?? m.created_at
20181
- });
21693
+ out.push(bucketShareItem(p, m));
20182
21694
  }
20183
21695
  }
20184
21696
  return out;
@@ -20208,10 +21720,16 @@ async function revokeShareByRequestId(db, requestId) {
20208
21720
  const share = getShareByRequestId(db, requestId);
20209
21721
  return share ? revokeShareAction(db, share.slug) : { ok: false, status: "not_found" };
20210
21722
  }
20211
- async function listSharesAggregated(db, targetId) {
21723
+ async function listSharesAggregated(db, targetId, opts = {}) {
20212
21724
  const bySlug = new Map;
20213
- for (const item of await listSharesLocal(db, targetId))
21725
+ for (const item of await listSharesLocal(db, targetId, opts))
20214
21726
  bySlug.set(item.slug, item);
21727
+ if (opts.mode === "cached") {
21728
+ for (const it of cachedRemoteShares(db, targetId, { refresh: false }))
21729
+ if (it.sourceKind === "peer" && !bySlug.has(it.slug))
21730
+ bySlug.set(it.slug, it);
21731
+ return [...bySlug.values()];
21732
+ }
20215
21733
  await Promise.all(listShareServers(db).map(async ({ url, label }) => {
20216
21734
  const peer = getPeer2(db, url);
20217
21735
  if (!peer?.token)
@@ -20229,19 +21747,6 @@ async function listSharesAggregated(db, targetId) {
20229
21747
  }));
20230
21748
  return [...bySlug.values()];
20231
21749
  }
20232
- async function fetchPeerShares(url, token, targetId) {
20233
- const u = `${url.replace(/\/+$/, "")}/api/shares${targetId ? `?target=${encodeURIComponent(targetId)}` : ""}`;
20234
- const res = await Promise.race([
20235
- fetch(u, { headers: { authorization: `Bearer ${token}` } }),
20236
- new Promise((_r, rej) => setTimeout(() => rej(new Error("timeout")), PEER_LIST_TIMEOUT_MS))
20237
- ]);
20238
- if (!res.ok)
20239
- return [];
20240
- return (await res.json()).map((item) => ({
20241
- ...item,
20242
- ...item.url ? { url: absoluteShareUrl(item.url, url) } : {}
20243
- }));
20244
- }
20245
21750
  async function revokeShareAction(db, slug) {
20246
21751
  if (getShare(db, slug)) {
20247
21752
  const channels = listSiteChannelRows(db).filter((channel) => channel.audience === "link" && channel.target_ref === slug && channel.desired_state === "active");
@@ -20270,37 +21775,50 @@ async function revokeShareAction(db, slug) {
20270
21775
  status: "revoked"
20271
21776
  };
20272
21777
  }
21778
+ const owner = await bucketOwning(db, slug);
21779
+ if (!owner)
21780
+ return { ok: false, status: "not_found" };
21781
+ await deleteBucketShareObjects(JSON.parse(owner.peer.config), slug);
21782
+ dropRemoteShare(db, owner.peer.url, slug);
21783
+ return { ok: true, status: "revoked" };
21784
+ }
21785
+ async function bucketOwning(db, slug) {
21786
+ const cached = findCachedBucketShare(db, slug);
21787
+ if (cached)
21788
+ return { peer: cached.source.peer, kind: cached.item.kind, item: cached.item };
20273
21789
  for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20274
- const config = JSON.parse(p.config);
20275
- const metas = await listBucketShares(config).catch(() => []);
20276
- if (metas.some((m) => m.slug === slug)) {
20277
- await deleteBucketShareObjects(config, slug);
20278
- return { ok: true, status: "revoked" };
20279
- }
21790
+ const metas = await listBucketShares(JSON.parse(p.config)).catch(() => []);
21791
+ const m = metas.find((x) => x.slug === slug);
21792
+ if (m)
21793
+ return { peer: p, kind: m.kind, item: null };
20280
21794
  }
20281
- return { ok: false, status: "not_found" };
21795
+ return null;
20282
21796
  }
20283
21797
  async function renewShareAction(db, slug, viewerBaseOverride, opts) {
20284
- for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20285
- const config = JSON.parse(p.config);
20286
- const metas = await listBucketShares(config).catch(() => []);
20287
- const m = metas.find((x) => x.slug === slug);
20288
- if (!m)
20289
- continue;
20290
- const base = viewerBase(db, viewerBaseOverride);
20291
- const out = opts?.refreshContent ? await renewBucketShare(db, config, slug) : await represignBucketShare(config, slug);
20292
- return {
20293
- slug,
20294
- kind: m.kind,
20295
- permission: "view",
20296
- transport: "s3",
20297
- hosting: "s3",
20298
- url: bucketLink(base, out.manifestUrl, out.keyB64, out.saltB64),
21798
+ const owner = await bucketOwning(db, slug);
21799
+ if (!owner)
21800
+ throw new MhError("not_found", `no such object-storage share: ${slug}`);
21801
+ const { peer: p } = owner;
21802
+ const config = JSON.parse(p.config);
21803
+ const base = viewerBase(db, viewerBaseOverride);
21804
+ const out = opts?.refreshContent ? await renewBucketShare(db, config, slug) : await represignBucketShare(config, slug);
21805
+ if (owner.item)
21806
+ primeRemoteShare(db, p.url, {
21807
+ ...owner.item,
21808
+ title: out.title || owner.item.title,
20299
21809
  expiresAt: out.presignExp,
20300
- source: `\u6876 ${p.label ?? p.url}`
20301
- };
20302
- }
20303
- throw new MhError("not_found", `no such object-storage share: ${slug}`);
21810
+ ...opts?.refreshContent ? { contentUpdatedAt: Date.now() } : {}
21811
+ });
21812
+ return {
21813
+ slug,
21814
+ kind: owner.kind,
21815
+ permission: "view",
21816
+ transport: "s3",
21817
+ hosting: "s3",
21818
+ url: bucketLink(base, out.manifestUrl, out.keyB64, out.saltB64),
21819
+ expiresAt: out.presignExp,
21820
+ source: `\u6876 ${p.label ?? p.url}`
21821
+ };
20304
21822
  }
20305
21823
  function freshSlug(db) {
20306
21824
  let slug = randomSuffix(12);
@@ -20399,7 +21917,7 @@ var shareRoutes = [
20399
21917
  handler: async (req, { db }) => {
20400
21918
  try {
20401
21919
  const target = new URL(req.url).searchParams.get("target") ?? undefined;
20402
- return Response.json(await listSharesLocal(db, target));
21920
+ return Response.json(await listSharesLocal(db, target, { mode: "cached" }));
20403
21921
  } catch (e) {
20404
21922
  return errorResponse(e);
20405
21923
  }
@@ -20413,7 +21931,7 @@ var shareRoutes = [
20413
21931
  handler: async (req, { db }) => {
20414
21932
  try {
20415
21933
  const target = new URL(req.url).searchParams.get("target") ?? undefined;
20416
- return Response.json(await listSharesAggregated(db, target));
21934
+ return Response.json(await listSharesAggregated(db, target, { mode: "cached" }));
20417
21935
  } catch (e) {
20418
21936
  return errorResponse(e);
20419
21937
  }
@@ -20704,7 +22222,7 @@ function jsonHandler(fn) {
20704
22222
  try {
20705
22223
  return Response.json(await fn(req, ctx) ?? null);
20706
22224
  } catch (e) {
20707
- return errorResponse(e);
22225
+ return errorResponse(e, req);
20708
22226
  }
20709
22227
  };
20710
22228
  }
@@ -20874,20 +22392,24 @@ async function verifyBase(input, expectedNode, allowRemote = true) {
20874
22392
  headers: { accept: "application/json" }
20875
22393
  });
20876
22394
  if (res.status < 200 || res.status >= 300)
20877
- throw new MhError("network", `\u7AD9\u70B9\u5165\u53E3\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF08HTTP ${res.status}\uFF09`);
22395
+ throw mhError("network", "\u7AD9\u70B9\u5165\u53E3\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF08HTTP {status}\uFF09", { status: res.status });
20878
22396
  const body = await limitedJson(res, HEALTH_RESPONSE_MAX);
20879
22397
  if (!body?.ok || !body.node)
20880
22398
  throw new MhError("network", "\u7AD9\u70B9\u5165\u53E3\u5065\u5EB7\u54CD\u5E94\u65E0\u6548");
20881
22399
  if (body.node !== expectedNode)
20882
- throw new MhError("conflict", `\u7AD9\u70B9\u5165\u53E3\u8282\u70B9\u4E0D\u5339\u914D\uFF08\u671F\u671B ${expectedNode}\uFF0C\u5B9E\u9645 ${body.node}\uFF09\uFF1B\u8FD9\u662F\u9632\u8BEF\u914D\u68C0\u67E5\uFF0C\u4E0D\u662F\u8EAB\u4EFD\u8BA4\u8BC1`);
22400
+ throw mhError("conflict", "\u7AD9\u70B9\u5165\u53E3\u8282\u70B9\u4E0D\u5339\u914D\uFF08\u671F\u671B {expected}\uFF0C\u5B9E\u9645 {actual}\uFF09\uFF1B\u8FD9\u662F\u9632\u8BEF\u914D\u68C0\u67E5\uFF0C\u4E0D\u662F\u8EAB\u4EFD\u8BA4\u8BC1", {
22401
+ expected: expectedNode,
22402
+ actual: body.node
22403
+ });
20883
22404
  if (!Array.isArray(body.capabilities) || !body.capabilities.includes("site_channels"))
20884
22405
  throw new MhError("conflict", "\u76EE\u6807\u8BBE\u5907\u7248\u672C\u4E0D\u652F\u6301\u5B9A\u5411\u53D1\u5E03\u6E20\u9053\uFF1B\u8BF7\u5148\u5347\u7EA7\u8BE5\u8BBE\u5907\uFF0C\u518D\u91CD\u65B0\u9A8C\u8BC1\u5165\u53E3");
20885
22406
  return { ...base, node: body.node };
20886
22407
  } catch (e) {
20887
22408
  if (e instanceof MhError)
20888
22409
  throw e;
20889
- const message = e.name === "AbortError" ? "\u8FDE\u63A5\u8D85\u65F6" : e.message;
20890
- throw new MhError("network", `\u65E0\u6CD5\u9A8C\u8BC1\u7AD9\u70B9\u5165\u53E3\uFF1A${message}`);
22410
+ if (e.name === "AbortError")
22411
+ throw mhError("network", "\u65E0\u6CD5\u9A8C\u8BC1\u7AD9\u70B9\u5165\u53E3\uFF1A\u8FDE\u63A5\u8D85\u65F6");
22412
+ throw mhError("network", "\u65E0\u6CD5\u9A8C\u8BC1\u7AD9\u70B9\u5165\u53E3\uFF1A{msg}", { msg: e.message });
20891
22413
  } finally {
20892
22414
  clearTimeout(timer);
20893
22415
  }
@@ -21208,6 +22730,7 @@ var siteHostingRoutes = [
21208
22730
  // src/webui/server/edge-routes.ts
21209
22731
  init_zod();
21210
22732
  init_errors();
22733
+ init_page_messages();
21211
22734
 
21212
22735
  // src/core/sync/edge-service.ts
21213
22736
  init_errors();
@@ -21374,6 +22897,7 @@ init_errors();
21374
22897
  init_http_status();
21375
22898
  init_ids();
21376
22899
  init_access_policy();
22900
+ init_page_messages();
21377
22901
  init_grants_routes();
21378
22902
  init_rate_limit();
21379
22903
  init_sites_core();
@@ -21855,6 +23379,7 @@ function disconnectEdge(db) {
21855
23379
 
21856
23380
  // src/core/sync/cf-oauth.ts
21857
23381
  init_errors();
23382
+ init_page_messages();
21858
23383
  init_e2ee();
21859
23384
  var CF_OAUTH_CLIENT_ID = process.env.METAHUB_CF_OAUTH_CLIENT_ID || "0062224f5b80ba3a2074780e73a9d305";
21860
23385
  var AUTH_ENDPOINT = "https://dash.cloudflare.com/oauth2/auth";
@@ -21904,7 +23429,7 @@ function buildTokenRequestBody(p) {
21904
23429
  function parseTokenResponse(data) {
21905
23430
  const d = data ?? {};
21906
23431
  if (!d.access_token)
21907
- throw new MhError("auth", `Cloudflare \u6388\u6743\u5931\u8D25\uFF1A${d.error_description || d.error || "\u672A\u8FD4\u56DE access token"}`);
23432
+ throw mhError("auth", "Cloudflare \u6388\u6743\u5931\u8D25\uFF1A{detail}", { detail: d.error_description || d.error || "\u672A\u8FD4\u56DE access token" });
21908
23433
  return {
21909
23434
  accessToken: d.access_token,
21910
23435
  refreshToken: d.refresh_token ?? null,
@@ -21924,11 +23449,11 @@ async function exchangeCode(p) {
21924
23449
  body: buildTokenRequestBody(p)
21925
23450
  });
21926
23451
  } catch (e) {
21927
- throw new MhError("network", `\u65E0\u6CD5\u8FDE\u63A5 Cloudflare \u6388\u6743\u670D\u52A1\uFF1A${e.message}`);
23452
+ throw mhError("network", "\u65E0\u6CD5\u8FDE\u63A5 Cloudflare \u6388\u6743\u670D\u52A1\uFF1A{msg}", { msg: e.message });
21928
23453
  }
21929
23454
  const data = await res.json().catch(() => null);
21930
23455
  if (!res.ok && !data?.access_token)
21931
- throw new MhError("auth", `Cloudflare \u4EE4\u724C\u4EA4\u6362\u5931\u8D25\uFF08HTTP ${res.status}\uFF09`);
23456
+ throw mhError("auth", "Cloudflare \u4EE4\u724C\u4EA4\u6362\u5931\u8D25\uFF08HTTP {status}\uFF09", { status: res.status });
21932
23457
  return parseTokenResponse(data);
21933
23458
  }
21934
23459
  async function discoverAccounts(accessToken) {
@@ -21938,11 +23463,11 @@ async function discoverAccounts(accessToken) {
21938
23463
  headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }
21939
23464
  });
21940
23465
  } catch (e) {
21941
- throw new MhError("network", `\u65E0\u6CD5\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5217\u8868\uFF1A${e.message}`);
23466
+ throw mhError("network", "\u65E0\u6CD5\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5217\u8868\uFF1A{msg}", { msg: e.message });
21942
23467
  }
21943
23468
  const data = await res.json().catch(() => null);
21944
23469
  if (!res.ok)
21945
- throw new MhError("auth", `\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5931\u8D25\uFF08HTTP ${res.status}\uFF09`);
23470
+ throw mhError("auth", "\u8BFB\u53D6 Cloudflare \u8D26\u53F7\u5931\u8D25\uFF08HTTP {status}\uFF09", { status: res.status });
21946
23471
  return parseAccounts(data);
21947
23472
  }
21948
23473
  async function startCfLogin(opts = {}) {
@@ -21961,7 +23486,9 @@ async function startCfLogin(opts = {}) {
21961
23486
  if (url.pathname !== CALLBACK_PATH)
21962
23487
  return new Response("not found", { status: 404 });
21963
23488
  settle(url.searchParams);
21964
- return new Response(CALLBACK_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
23489
+ return new Response(callbackHtml(localeFromAcceptLanguage(req.headers.get("accept-language"))), {
23490
+ headers: { "content-type": "text/html; charset=utf-8" }
23491
+ });
21965
23492
  }
21966
23493
  });
21967
23494
  const redirectUri = `http://127.0.0.1:${server.port}${CALLBACK_PATH}`;
@@ -21992,7 +23519,7 @@ async function startCfLogin(opts = {}) {
21992
23519
  try {
21993
23520
  const q = await Promise.race([caught, timeout]);
21994
23521
  if (q.get("error"))
21995
- throw new MhError("auth", `Cloudflare \u6388\u6743\u88AB\u62D2\u7EDD\uFF1A${q.get("error_description") || q.get("error")}`);
23522
+ throw mhError("auth", "Cloudflare \u6388\u6743\u88AB\u62D2\u7EDD\uFF1A{detail}", { detail: q.get("error_description") || q.get("error") || "" });
21996
23523
  if (q.get("state") !== state)
21997
23524
  throw new MhError("auth", "OAuth state \u4E0D\u5339\u914D\uFF0C\u7591\u4F3C\u4F2A\u9020\u56DE\u8C03\uFF0C\u5DF2\u4E2D\u6B62");
21998
23525
  const code = q.get("code");
@@ -22013,9 +23540,9 @@ async function startCfLogin(opts = {}) {
22013
23540
  }
22014
23541
  };
22015
23542
  }
22016
- var CALLBACK_HTML = `<!doctype html><meta charset="utf-8"><title>MetaHub</title>
23543
+ var callbackHtml = (locale) => `<!doctype html><html lang="${locale}"><meta charset="utf-8"><title>MetaHub</title>
22017
23544
  <body style="font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0">
22018
- <div style="text-align:center"><h2>\u2705 \u5DF2\u6388\u6743</h2><p>\u53EF\u4EE5\u5173\u95ED\u6B64\u6807\u7B7E\u9875\uFF0C\u56DE\u5230 MetaHub \u7EE7\u7EED\u90E8\u7F72\u3002</p></div>`;
23545
+ <div style="text-align:center"><h2>${pm(locale, "\u2705 \u5DF2\u6388\u6743")}</h2><p>${pm(locale, "\u53EF\u4EE5\u5173\u95ED\u6B64\u6807\u7B7E\u9875\uFF0C\u56DE\u5230 MetaHub \u7EE7\u7EED\u90E8\u7F72\u3002")}</p></div>`;
22019
23546
 
22020
23547
  // src/webui/server/edge-routes.ts
22021
23548
  init_ids();
@@ -22086,7 +23613,7 @@ function resolveCfCreds(body) {
22086
23613
  if (!flow)
22087
23614
  throw new MhError("not_found", "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55");
22088
23615
  if (flow.state === "error")
22089
- throw new MhError("auth", flow.error || "Cloudflare \u6388\u6743\u5931\u8D25");
23616
+ throw new MhError("auth", flow.error || "Cloudflare \u6388\u6743\u5931\u8D25", flow.errorI18n);
22090
23617
  if (flow.state !== "ready" || !flow.token)
22091
23618
  throw new MhError("conflict", "Cloudflare \u6388\u6743\u5C1A\u672A\u5B8C\u6210\uFF0C\u8BF7\u7A0D\u5019");
22092
23619
  apiToken = flow.token;
@@ -22130,6 +23657,7 @@ var edgeRoutes = [
22130
23657
  }).catch((e) => {
22131
23658
  flow.state = "error";
22132
23659
  flow.error = e.message;
23660
+ flow.errorI18n = e instanceof MhError ? e.i18n : undefined;
22133
23661
  });
22134
23662
  return { flowId, authUrl: handle.authUrl };
22135
23663
  })
@@ -22148,7 +23676,8 @@ var edgeRoutes = [
22148
23676
  const flow = flows.get(flowId);
22149
23677
  if (!flow)
22150
23678
  throw new MhError("not_found", "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5");
22151
- return { state: flow.state, accounts: flow.accounts, error: flow.error };
23679
+ const error = flow.error === undefined ? undefined : flow.errorI18n ? pmIfKnown(pickLocale(req), flow.errorI18n.key, flow.errorI18n.params) : pmIfKnown(pickLocale(req), flow.error);
23680
+ return { state: flow.state, accounts: flow.accounts, error };
22152
23681
  })
22153
23682
  },
22154
23683
  {
@@ -22250,6 +23779,7 @@ var DatabaseSchema = object({
22250
23779
  id: string2(),
22251
23780
  name: string2(),
22252
23781
  icon: string2().nullable(),
23782
+ meta: record(string2(), any()).nullable(),
22253
23783
  created_hlc: string2()
22254
23784
  });
22255
23785
  var PropertySchema = object({
@@ -22293,11 +23823,18 @@ var SearchHitSchema = object({
22293
23823
  title: string2().optional(),
22294
23824
  snippet: string2()
22295
23825
  });
23826
+ var LookupHitSchema = object({
23827
+ kind: _enum(["db", "doc", "rec"]),
23828
+ id: string2(),
23829
+ label: string2(),
23830
+ database_id: string2().nullable()
23831
+ });
22296
23832
  var OkSchema2 = object({ ok: boolean2() });
22297
23833
  var CreateDatabaseReq = object({ name: string2(), icon: string2().optional() });
22298
23834
  var UpdateDatabaseReq = object({
22299
23835
  name: string2().optional(),
22300
- icon: string2().nullable().optional()
23836
+ icon: string2().nullable().optional(),
23837
+ meta: record(string2(), any()).nullable().optional().describe("Merge patch over per-key metadata; null value deletes a key, null object clears all")
22301
23838
  });
22302
23839
  var DuplicateDatabaseReq = object({
22303
23840
  name: string2().optional().describe("Name for the copy (defaults to the source name)"),
@@ -22468,8 +24005,11 @@ var FieldHistoryEntrySchema = object({
22468
24005
  });
22469
24006
  var NodeInfoSchema = object({
22470
24007
  node_id: string2(),
22471
- label: string2().nullable().describe("Peer label from pairing; null when unnamed"),
22472
- self: boolean2().describe("True for the node serving this request")
24008
+ label: string2().nullable().describe("Synced roster name \u2192 pairing label \u2192 null when unnamed"),
24009
+ self: boolean2().describe("True for the node serving this request"),
24010
+ platform: _enum(["macos", "windows", "linux", "ios", "android", "web"]).nullable(),
24011
+ form: _enum(["laptop", "desktop", "phone", "server", "browser"]).nullable(),
24012
+ app: _enum(["cli", "server", "desktop", "web"]).nullable()
22473
24013
  });
22474
24014
  var CacheStatsSchema = object({
22475
24015
  totalBytes: number2(),
@@ -22553,7 +24093,7 @@ function handle3(fn) {
22553
24093
  return out;
22554
24094
  return Response.json(out ?? null);
22555
24095
  } catch (e) {
22556
- return errorResponse(e);
24096
+ return errorResponse(e, req);
22557
24097
  }
22558
24098
  };
22559
24099
  }
@@ -22616,7 +24156,7 @@ var webuiRoutes = [
22616
24156
  {
22617
24157
  method: "PATCH",
22618
24158
  path: "/api/database",
22619
- summary: "Rename a database or change its icon. Query: ?id=<id>",
24159
+ summary: "Rename a database, change its icon or patch its metadata. Query: ?id=<id>",
22620
24160
  request: UpdateDatabaseReq,
22621
24161
  response: DatabaseSchema,
22622
24162
  handler: handle3(async (req, { db }) => {
@@ -22979,13 +24519,17 @@ var webuiRoutes = [
22979
24519
  {
22980
24520
  method: "PATCH",
22981
24521
  path: "/api/node",
22982
- summary: "Set this device's display label (empty/null clears it)",
22983
- request: object({ label: string2().nullable().optional() }),
24522
+ summary: "Rename a device (default: this one). Writes the synced roster; empty/null clears",
24523
+ request: object({
24524
+ label: string2().nullable().optional(),
24525
+ node_id: string2().optional().describe("Target node; omitted = this device")
24526
+ }),
22984
24527
  response: NodeInfoSchema,
22985
24528
  handler: handle3(async (req, { db }) => {
22986
24529
  const body = await req.json();
22987
- setNodeLabel(db, body.label ?? null);
22988
- return { node_id: getNodeId(db), label: getNodeLabel(db), self: true };
24530
+ const id = body.node_id ?? getNodeId(db);
24531
+ setNodeLabel(db, body.label ?? null, id);
24532
+ return displayNodes(db).find((n) => n.node_id === id);
22989
24533
  })
22990
24534
  },
22991
24535
  {
@@ -22998,6 +24542,16 @@ var webuiRoutes = [
22998
24542
  return search(db, need3(req, "q"), { limit: limit ? Number(limit) : undefined });
22999
24543
  })
23000
24544
  },
24545
+ {
24546
+ method: "GET",
24547
+ path: "/api/resolve",
24548
+ summary: "Navigable entities (db/doc/rec) matching a full id, id prefix, bare slug or name \u2014 the `mh get <ref>` rule. Query: ?ref=<text>&limit=<n>",
24549
+ response: array(LookupHitSchema),
24550
+ handler: handle3((req, { db }) => {
24551
+ const limit = opt(req, "limit");
24552
+ return lookupCandidates(db, need3(req, "ref"), { limit: limit ? Number(limit) : undefined });
24553
+ })
24554
+ },
23001
24555
  {
23002
24556
  method: "GET",
23003
24557
  path: "/api/blob-cache",
@@ -23022,6 +24576,13 @@ var webuiRoutes = [
23022
24576
  response: ClearResultSchema,
23023
24577
  handler: handle3((_req, { db }) => clearCache(db))
23024
24578
  },
24579
+ {
24580
+ method: "POST",
24581
+ path: "/api/repair/clock",
24582
+ summary: "Restamp oplog rows dated more than 5 min in the future with this clock and reset the logical clock (fixes clock_skew; local-only).",
24583
+ response: object({ restamped: number2(), byNode: record(string2(), number2()) }),
24584
+ handler: handle3(async (_req, { db }) => repairClock(db))
24585
+ },
23025
24586
  {
23026
24587
  method: "POST",
23027
24588
  path: "/api/blob-cache/pin",
@@ -23095,6 +24656,7 @@ export {
23095
24656
  DOCUMENT_COLS,
23096
24657
  DOMAIN,
23097
24658
  HEALTH_PATH,
24659
+ HLC_MAX_SKEW_MS,
23098
24660
  HealthResponseSchema,
23099
24661
  INLINE_LIMIT,
23100
24662
  MAX_HLC_COUNTER,
@@ -23120,6 +24682,7 @@ export {
23120
24682
  advanceAckedPrefix,
23121
24683
  appendDocument,
23122
24684
  applyChange,
24685
+ asMhError,
23123
24686
  backfillDocumentOrderKeys,
23124
24687
  backfillRecordOrderKeys,
23125
24688
  base64ToBytes,
@@ -23139,12 +24702,14 @@ export {
23139
24702
  createSite,
23140
24703
  dbPath,
23141
24704
  decodeFileRow,
24705
+ defaultForm,
23142
24706
  deleteBlob,
23143
24707
  deleteDatabase,
23144
24708
  deleteDocument,
23145
24709
  deleteFile,
23146
24710
  deleteRecord,
23147
24711
  deleteSite,
24712
+ describeSelf,
23148
24713
  displayNodes,
23149
24714
  documentTitleMap,
23150
24715
  documentVersion,
@@ -23178,28 +24743,36 @@ export {
23178
24743
  getSiteByName,
23179
24744
  grouped,
23180
24745
  hasIndex,
24746
+ hlcSkewBound,
23181
24747
  idKind,
23182
24748
  inferContentType,
23183
24749
  ingest,
24750
+ ingestDetailed,
23184
24751
  initSchema,
24752
+ interpolateMsg,
23185
24753
  isImageType,
23186
24754
  isSitePublic,
24755
+ isSkewedHlc,
23187
24756
  isTextType,
23188
24757
  keyBetween,
23189
24758
  keysBetween,
23190
24759
  listDatabases,
23191
24760
  listDocuments,
23192
24761
  listFiles,
24762
+ listNodeMeta,
23193
24763
  listProperties,
23194
24764
  listRecords,
23195
24765
  listSites,
23196
24766
  liveFileRowIfSame,
24767
+ lookupCandidates,
23197
24768
  loopbackUiRejection,
23198
24769
  makeId,
23199
24770
  maybeAutoIndex,
23200
24771
  metahubHome,
24772
+ mhError,
23201
24773
  migrateDocBlocks,
23202
24774
  migrateDocuments,
24775
+ migrateNodes,
23203
24776
  migrateOplog,
23204
24777
  migratePeers,
23205
24778
  migrateRecords,
@@ -23208,12 +24781,15 @@ export {
23208
24781
  moveRecord,
23209
24782
  newId,
23210
24783
  nextHlc,
24784
+ nodeLabelOf,
23211
24785
  normalizeSiteName,
23212
24786
  normalizeSitePath,
23213
24787
  observeHlc,
23214
24788
  openMetahub,
23215
24789
  parseDocBlocks,
23216
24790
  parseHlc,
24791
+ persistedHlc,
24792
+ platformFromProcess,
23217
24793
  prepareRecordCells,
23218
24794
  prependDocument,
23219
24795
  publishDirectory,
@@ -23222,12 +24798,14 @@ export {
23222
24798
  putFile,
23223
24799
  putFileInline,
23224
24800
  randomSuffix,
24801
+ readNodeMeta,
23225
24802
  rebuildSearchIndex,
23226
24803
  reconcile,
23227
24804
  recordTitleMap,
23228
24805
  removeProperty,
23229
24806
  removeSelectOption,
23230
24807
  renameSelectOption,
24808
+ resetHlc,
23231
24809
  resolveCandidates,
23232
24810
  resolveData,
23233
24811
  resolveEntity,
@@ -23247,6 +24825,7 @@ export {
23247
24825
  sha256Hex,
23248
24826
  siteCacheControl,
23249
24827
  siteLifecycle,
24828
+ skewWarnings,
23250
24829
  slugify,
23251
24830
  startServer,
23252
24831
  syncWithPeer,
@@ -23264,7 +24843,8 @@ export {
23264
24843
  withChangeGroup,
23265
24844
  withNodeId,
23266
24845
  withTxnId,
23267
- writeFileRow
24846
+ writeFileRow,
24847
+ writeTx
23268
24848
  };
23269
24849
 
23270
- //# debugId=CA4BC603573EC3ED64756E2164756E21
24850
+ //# debugId=23C620FE660E43F464756E2164756E21