@tensorix/metahub 0.5.2 → 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 (90) hide show
  1. package/README.md +8 -4
  2. package/README.zh-CN.md +9 -5
  3. package/dist/cli.js +4945 -3979
  4. package/dist/cli.js.map +63 -57
  5. package/dist/core/compact.d.ts.map +1 -1
  6. package/dist/core/config.d.ts.map +1 -1
  7. package/dist/core/crdt.d.ts +12 -4
  8. package/dist/core/crdt.d.ts.map +1 -1
  9. package/dist/core/databases.d.ts +3 -2
  10. package/dist/core/databases.d.ts.map +1 -1
  11. package/dist/core/db.d.ts.map +1 -1
  12. package/dist/core/documents.d.ts.map +1 -1
  13. package/dist/core/driver.d.ts +5 -1
  14. package/dist/core/driver.d.ts.map +1 -1
  15. package/dist/core/errors.d.ts +19 -1
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/guest-intent.d.ts +1 -1
  18. package/dist/core/guest-intent.d.ts.map +1 -1
  19. package/dist/core/history-cache.d.ts +13 -0
  20. package/dist/core/history-cache.d.ts.map +1 -0
  21. package/dist/core/history.d.ts.map +1 -1
  22. package/dist/core/hlc.d.ts +13 -0
  23. package/dist/core/hlc.d.ts.map +1 -1
  24. package/dist/core/integrity.d.ts +15 -3
  25. package/dist/core/integrity.d.ts.map +1 -1
  26. package/dist/core/resolve.d.ts +8 -0
  27. package/dist/core/resolve.d.ts.map +1 -1
  28. package/dist/core/schema-init.d.ts +1 -1
  29. package/dist/core/schema-init.d.ts.map +1 -1
  30. package/dist/core/search.d.ts +1 -1
  31. package/dist/core/search.d.ts.map +1 -1
  32. package/dist/core/site-channel-store.d.ts +1 -1
  33. package/dist/core/site-channel-store.d.ts.map +1 -1
  34. package/dist/core/snapshot.d.ts.map +1 -1
  35. package/dist/core/sync/auth.d.ts +2 -4
  36. package/dist/core/sync/auth.d.ts.map +1 -1
  37. package/dist/core/sync/cf-oauth.d.ts.map +1 -1
  38. package/dist/core/sync/client.d.ts +4 -0
  39. package/dist/core/sync/client.d.ts.map +1 -1
  40. package/dist/core/sync/drop-protocol.d.ts.map +1 -1
  41. package/dist/core/sync/drop-pull.d.ts +1 -1
  42. package/dist/core/sync/drop-pull.d.ts.map +1 -1
  43. package/dist/core/sync/http-status.d.ts.map +1 -1
  44. package/dist/core/sync/locale.d.ts +8 -0
  45. package/dist/core/sync/locale.d.ts.map +1 -0
  46. package/dist/core/sync/page-messages.d.ts +106 -0
  47. package/dist/core/sync/page-messages.d.ts.map +1 -0
  48. package/dist/core/sync/pairing.d.ts +4 -1
  49. package/dist/core/sync/pairing.d.ts.map +1 -1
  50. package/dist/core/sync/peers.d.ts +1 -1
  51. package/dist/core/sync/peers.d.ts.map +1 -1
  52. package/dist/core/sync/protocol.d.ts +1 -0
  53. package/dist/core/sync/protocol.d.ts.map +1 -1
  54. package/dist/core/sync/remote-shares-cache.d.ts +32 -0
  55. package/dist/core/sync/remote-shares-cache.d.ts.map +1 -0
  56. package/dist/core/sync/room-client.d.ts +1 -1
  57. package/dist/core/sync/room-client.d.ts.map +1 -1
  58. package/dist/core/sync/room-protocol.d.ts +1 -1
  59. package/dist/core/sync/room-protocol.d.ts.map +1 -1
  60. package/dist/core/sync/room-serve.d.ts.map +1 -1
  61. package/dist/core/sync/routes.d.ts +1 -1
  62. package/dist/core/sync/routes.d.ts.map +1 -1
  63. package/dist/core/sync/server.d.ts +2 -0
  64. package/dist/core/sync/server.d.ts.map +1 -1
  65. package/dist/core/sync/share-actions.d.ts +7 -2
  66. package/dist/core/sync/share-actions.d.ts.map +1 -1
  67. package/dist/core/sync/share-copy.d.ts +7 -0
  68. package/dist/core/sync/share-copy.d.ts.map +1 -0
  69. package/dist/core/sync/share-export.d.ts +3 -1
  70. package/dist/core/sync/share-export.d.ts.map +1 -1
  71. package/dist/core/sync/share-serve.d.ts.map +1 -1
  72. package/dist/core/sync/sites-serve.d.ts.map +1 -1
  73. package/dist/core/sync/storage.d.ts +1 -1
  74. package/dist/core/sync/storage.d.ts.map +1 -1
  75. package/dist/db-worker.js +115 -88
  76. package/dist/edge-worker.js +319 -84
  77. package/dist/index.js +1597 -384
  78. package/dist/index.js.map +52 -45
  79. package/dist/metahub-sdk.js +5 -5
  80. package/dist/mh-runtime.js +9 -1
  81. package/dist/sw.js +15 -7
  82. package/dist/webui/server/assets.d.ts.map +1 -1
  83. package/dist/webui/server/changes-route.d.ts +2 -0
  84. package/dist/webui/server/changes-route.d.ts.map +1 -1
  85. package/dist/webui/server/edge-routes.d.ts.map +1 -1
  86. package/dist/webui/server/routes.d.ts +2 -0
  87. package/dist/webui/server/routes.d.ts.map +1 -1
  88. package/dist/webui-fmt.js +1 -1
  89. package/dist/webui.js +173 -215
  90. 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 (
@@ -466,7 +496,7 @@ function setNodeLabel(db, label, nodeId) {
466
496
  return;
467
497
  if (!cur && next == null)
468
498
  return;
469
- withChangeGroup("node", () => {
499
+ withChangeGroup(db, "node", () => {
470
500
  emit(db, "nodes", id, "label", next);
471
501
  });
472
502
  }
@@ -488,7 +518,7 @@ function describeSelf(db, info = {}) {
488
518
  fields.label = seed;
489
519
  }
490
520
  if (Object.keys(fields).length > 0) {
491
- withChangeGroup("node", () => {
521
+ withChangeGroup(db, "node", () => {
492
522
  for (const [col, v] of Object.entries(fields))
493
523
  emit(db, "nodes", id, col, v);
494
524
  });
@@ -556,6 +586,12 @@ var init_node = __esm(() => {
556
586
  });
557
587
 
558
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
+ }
559
595
  function carryCounter(millis, counter) {
560
596
  if (counter <= MAX_HLC_COUNTER)
561
597
  return { millis, counter };
@@ -586,6 +622,8 @@ function writeLast(db, h) {
586
622
  }
587
623
  function nextHlc(db, node, now = Date.now()) {
588
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) });
589
627
  const millis = Math.max(last.millis, now);
590
628
  const counter = millis === last.millis ? last.counter + 1 : 0;
591
629
  const h = { ...carryCounter(millis, counter), node };
@@ -597,6 +635,8 @@ function observeHlc(db, node, remote, now = Date.now()) {
597
635
  const r = parseHlc(remote);
598
636
  if (!Number.isFinite(r.millis) || !Number.isFinite(r.counter))
599
637
  return;
638
+ if (r.millis > now + HLC_MAX_SKEW_MS)
639
+ return;
600
640
  const millis = Math.max(last.millis, r.millis, now);
601
641
  let counter;
602
642
  if (millis === last.millis && millis === r.millis)
@@ -609,7 +649,18 @@ function observeHlc(db, node, remote, now = Date.now()) {
609
649
  counter = 0;
610
650
  writeLast(db, { ...carryCounter(millis, counter), node });
611
651
  }
612
- 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
+ });
613
664
 
614
665
  // src/core/blocks.ts
615
666
  function parseDocBlocks(md) {
@@ -712,6 +763,78 @@ function lcs(a, b) {
712
763
  return pairs;
713
764
  }
714
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
+
715
838
  // src/core/intent-retention.ts
716
839
  function safeNow(now) {
717
840
  return Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
@@ -760,27 +883,27 @@ function withNodeId(node, fn) {
760
883
  currentNodeOverride = prev;
761
884
  }
762
885
  }
763
- function withChangeGroup(label, fn) {
886
+ function withChangeGroup(db, label, fn) {
764
887
  if (currentTxn !== null)
765
888
  return fn();
766
889
  currentTxn = (currentActor ? currentActor + "/" : "") + (label ? label + ":" : "") + randomSuffix(8);
767
890
  try {
768
- return fn();
891
+ return writeTx(db, fn);
769
892
  } finally {
770
893
  currentTxn = null;
771
894
  }
772
895
  }
773
- function withTxnId(txn, fn) {
896
+ function withTxnId(db, txn, fn) {
774
897
  const prev = currentTxn;
775
898
  currentTxn = txn;
776
899
  try {
777
- return fn();
900
+ return writeTx(db, fn);
778
901
  } finally {
779
902
  currentTxn = prev;
780
903
  }
781
904
  }
782
905
  function grouped(fn, label = null) {
783
- return (...args) => withChangeGroup(label, () => fn(...args));
906
+ return (db, ...args) => withChangeGroup(db, label, () => fn(db, ...args));
784
907
  }
785
908
  function decodeMaterializeValue(dataset, col, valueJson) {
786
909
  if (valueJson !== null)
@@ -827,6 +950,16 @@ function materialize(db, dataset, rowId, col, valueJson) {
827
950
  }
828
951
  return;
829
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
+ }
830
963
  const d = DOMAIN[dataset];
831
964
  if (!d || !d.cols.has(col))
832
965
  return;
@@ -844,6 +977,8 @@ function materialize(db, dataset, rowId, col, valueJson) {
844
977
  function applyChange(db, c) {
845
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;
846
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);
847
982
  if (cur.h !== c.hlc)
848
983
  return { inserted, winner: false };
849
984
  try {
@@ -857,18 +992,21 @@ function applyChange(db, c) {
857
992
  return { inserted, winner: true };
858
993
  }
859
994
  function emit(db, dataset, rowId, col, value) {
860
- const node = currentNodeOverride ?? getNodeId(db);
861
- const change = {
862
- hlc: nextHlc(db, node),
863
- node_id: node,
864
- dataset,
865
- row_id: rowId,
866
- col,
867
- value: value === undefined ? null : JSON.stringify(value),
868
- 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;
869
1008
  };
870
- applyChange(db, change);
871
- return change;
1009
+ return currentTxn !== null ? mint() : writeTx(db, mint);
872
1010
  }
873
1011
  function emitFields(db, dataset, rowId, fields) {
874
1012
  const out = [];
@@ -878,16 +1016,24 @@ function emitFields(db, dataset, rowId, fields) {
878
1016
  return out;
879
1017
  }
880
1018
  function ingest(db, changes, opts = {}) {
1019
+ return ingestDetailed(db, changes, opts).received;
1020
+ }
1021
+ function ingestDetailed(db, changes, opts = {}) {
881
1022
  const now = opts.now ?? Date.now();
882
1023
  pruneExpiredIntentReceipts(db, now);
883
1024
  const node = getNodeId(db);
884
1025
  let received = 0;
885
1026
  let poisoned = 0;
886
- const tx = db.transaction((cs) => {
887
- for (const c of cs) {
1027
+ const skewed = {};
1028
+ const tx = () => {
1029
+ for (const c of changes) {
888
1030
  if (isExpiredIntentReceipt(c, now))
889
1031
  continue;
890
- 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);
891
1037
  try {
892
1038
  if (applyChange(db, c).inserted)
893
1039
  received++;
@@ -896,11 +1042,13 @@ function ingest(db, changes, opts = {}) {
896
1042
  console.error(`[ingest] skipped a change that failed to apply (dataset=${c.dataset} col=${c.col} row=${c.row_id}): ${e.message}`);
897
1043
  }
898
1044
  }
899
- });
900
- tx(changes);
1045
+ };
1046
+ writeTx(db, tx);
901
1047
  if (poisoned)
902
1048
  console.error(`[ingest] ${poisoned} change(s) skipped this batch (see above)`);
903
- 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 };
904
1052
  }
905
1053
  function changesSince(db, since, opts = {}) {
906
1054
  const rows = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes WHERE hlc > ? ORDER BY hlc`).all(since);
@@ -936,7 +1084,9 @@ function changesAfterSeq(db, seq, opts = {}) {
936
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;
937
1085
  var init_crdt = __esm(() => {
938
1086
  init_node();
1087
+ init_hlc();
939
1088
  init_ids();
1089
+ init_history_cache();
940
1090
  init_intent_retention();
941
1091
  CHANGE_COLS = ["hlc", "node_id", "dataset", "row_id", "col", "value", "txn"];
942
1092
  CHANGE_SELECT = CHANGE_COLS.join(", ");
@@ -1243,7 +1393,19 @@ var init_properties = __esm(() => {
1243
1393
  function rowOut(r) {
1244
1394
  if (!r)
1245
1395
  return null;
1246
- 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
+ }
1247
1409
  }
1248
1410
  function getDatabase(db, id) {
1249
1411
  return rowOut(db.query(`SELECT ${DATABASE_SELECT} FROM databases WHERE id = ? AND __deleted = 0`).get(id));
@@ -1282,7 +1444,9 @@ var init_databases = __esm(() => {
1282
1444
  if (fields.meta !== undefined) {
1283
1445
  if (fields.meta !== null && (typeof fields.meta !== "object" || Array.isArray(fields.meta)))
1284
1446
  throw new MhError("invalid_input", "meta must be a JSON object or null");
1285
- 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);
1286
1450
  }
1287
1451
  return getDatabase(db, id);
1288
1452
  });
@@ -1296,8 +1460,7 @@ var init_databases = __esm(() => {
1296
1460
  });
1297
1461
  if (src.meta !== null) {
1298
1462
  const { collapsed: _hidden, ...meta } = src.meta;
1299
- if (Object.keys(meta).length)
1300
- emit(db, "databases", dup.id, "meta", meta);
1463
+ emitMetaPatch(db, dup.id, null, meta);
1301
1464
  }
1302
1465
  const propIdMap = new Map;
1303
1466
  const selfRelNew = new Set;
@@ -1425,7 +1588,37 @@ ${fmt(shown)}
1425
1588
  function resolveRef(db, ref, opts = {}) {
1426
1589
  return resolveEntity(db, ref, opts).id;
1427
1590
  }
1428
- 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;
1429
1622
  var init_resolve = __esm(() => {
1430
1623
  init_ids();
1431
1624
  init_errors();
@@ -2126,25 +2319,6 @@ var init_documents = __esm(() => {
2126
2319
  throw new MhError("invalid_input", `anchor not found: no match for --old`);
2127
2320
  if (count > 1 && !opts.replaceAll)
2128
2321
  throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
2129
- if (!opts.old.includes(`
2130
-
2131
- `)) {
2132
- const affected = blocks.filter((b) => b.text.includes(opts.old));
2133
- const next = affected.map((b) => opts.replaceAll ? b.text.replaceAll(opts.old, () => opts.new) : b.text.replace(opts.old, () => opts.new));
2134
- if (next.every((t) => t.length > 0 && !t.includes(`
2135
-
2136
- `))) {
2137
- const targets = opts.replaceAll ? affected : affected.slice(0, 1);
2138
- let wrote = false;
2139
- targets.forEach((b, i) => {
2140
- if (next[i] !== b.text) {
2141
- emit(db, "doc_blocks", b.id, "text", next[i]);
2142
- wrote = true;
2143
- }
2144
- });
2145
- return { id, changed: wrote, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
2146
- }
2147
- }
2148
2322
  const newBody = opts.replaceAll ? body.replaceAll(opts.old, () => opts.new) : body.replace(opts.old, () => opts.new);
2149
2323
  reconcileBody(db, id, newBody);
2150
2324
  return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
@@ -2342,7 +2516,7 @@ function knownNodes(db) {
2342
2516
  return [...ids].map((nodeId) => ({ nodeId, label: nodeLabelOf(db, nodeId), self: nodeId === self }));
2343
2517
  }
2344
2518
  var POLICY_ID = "default", blobBytesResolver = null, setFullNodes = (db, nodeIds) => {
2345
- withChangeGroup(null, () => {
2519
+ withChangeGroup(db, null, () => {
2346
2520
  const seen = new Set;
2347
2521
  const list = nodeIds.filter((n) => n && !seen.has(n) && seen.add(n) != null);
2348
2522
  emit(db, "blob_policy", POLICY_ID, "full_nodes", list);
@@ -2405,7 +2579,7 @@ function migrateRecords(db) {
2405
2579
  db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
2406
2580
  if (!tableExists3(db, "record_values"))
2407
2581
  return;
2408
- const tx = db.transaction(() => {
2582
+ writeTx(db, () => {
2409
2583
  db.exec(`
2410
2584
  UPDATE records SET data = coalesce((
2411
2585
  SELECT json_group_object(rv.property_id, json(rv.value))
@@ -2416,7 +2590,6 @@ function migrateRecords(db) {
2416
2590
  db.exec("DROP TABLE record_values");
2417
2591
  backfillRecordOrderKeys(db);
2418
2592
  });
2419
- tx();
2420
2593
  backfillRecordOrderKeys(db);
2421
2594
  }
2422
2595
  function migrateShares(db) {
@@ -2474,7 +2647,7 @@ function migrateOplog(db) {
2474
2647
  function migrateCrdtChangesSeq(db) {
2475
2648
  if (hasColumn(db, "crdt_changes", "seq"))
2476
2649
  return;
2477
- const tx = db.transaction(() => {
2650
+ writeTx(db, () => {
2478
2651
  db.exec(`
2479
2652
  DROP TABLE IF EXISTS crdt_changes_new;
2480
2653
  CREATE TABLE crdt_changes_new (
@@ -2504,7 +2677,6 @@ function migrateCrdtChangesSeq(db) {
2504
2677
  if (tableExists3(db, "peers"))
2505
2678
  db.exec("UPDATE peers SET push_cursor = 0, pull_cursor = 0");
2506
2679
  });
2507
- tx();
2508
2680
  }
2509
2681
  function migrateStoragePeerUrls(db) {
2510
2682
  if (!tableExists3(db, "peers"))
@@ -2537,7 +2709,7 @@ function migrateStoragePeerUrls(db) {
2537
2709
  if (renames.length === 0)
2538
2710
  return;
2539
2711
  const map = new Map(renames.map(({ oldUrl, newUrl }) => [oldUrl, newUrl]));
2540
- const tx = db.transaction(() => {
2712
+ writeTx(db, () => {
2541
2713
  for (const { oldUrl, newUrl } of renames) {
2542
2714
  if (tableExists3(db, "storage_cursors"))
2543
2715
  db.query("UPDATE storage_cursors SET peer_url = ? WHERE peer_url = ?").run(newUrl, oldUrl);
@@ -2549,7 +2721,6 @@ function migrateStoragePeerUrls(db) {
2549
2721
  setFullNodes(db, fullNodes.map((n) => map.get(n) ?? n));
2550
2722
  }
2551
2723
  });
2552
- tx();
2553
2724
  }
2554
2725
  function migrateDatabases(db) {
2555
2726
  if (hasColumn(db, "databases", "meta"))
@@ -2602,11 +2773,11 @@ function replayDataset(db, dataset) {
2602
2773
  WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
2603
2774
  if (changes.length === 0)
2604
2775
  return;
2605
- db.transaction((rows) => {
2606
- for (const change of rows)
2776
+ writeTx(db, () => {
2777
+ for (const change of changes)
2607
2778
  applyChange(db, change);
2608
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, String(rows[rows.length - 1].seq));
2609
- })(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
+ });
2610
2781
  }
2611
2782
  function migrateSiteChannels(db) {
2612
2783
  replayDataset(db, "site_channels");
@@ -3080,7 +3251,7 @@ function writeFileRow(db, siteId, cleanPath, contentType, encoding, content) {
3080
3251
  return { ...same, changed: false };
3081
3252
  const existing = fileIdFor(db, siteId, cleanPath);
3082
3253
  const id = existing ?? newId("sf", cleanPath);
3083
- withChangeGroup(null, () => {
3254
+ withChangeGroup(db, null, () => {
3084
3255
  if (!existing) {
3085
3256
  const first = emit(db, "site_files", id, "site_id", siteId);
3086
3257
  emit(db, "site_files", id, "path", cleanPath);
@@ -8287,7 +8458,8 @@ var init_protocol = __esm(() => {
8287
8458
  SyncResponseSchema = object({
8288
8459
  node_id: string2().describe("Server's node id"),
8289
8460
  changes: array(ChangeSchema).describe("Server changes after `since`"),
8290
- 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")
8291
8463
  });
8292
8464
  HealthResponseSchema = object({
8293
8465
  ok: boolean2(),
@@ -8315,6 +8487,9 @@ function getPeer(db, url) {
8315
8487
  function setPeer(db, url, c) {
8316
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);
8317
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
+ }
8318
8493
  function advanceAckedPrefix(db, fromSeq, received) {
8319
8494
  if (received.length === 0)
8320
8495
  return fromSeq;
@@ -8353,12 +8528,21 @@ async function syncWithPeer(db, url, opts = {}) {
8353
8528
  throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `sync failed: ${res.status} ${await res.text()}`);
8354
8529
  const data = await res.json();
8355
8530
  const changes = data.changes ?? [];
8356
- const pulled = ingest(db, changes);
8531
+ const r = ingestDetailed(db, changes);
8532
+ const pulled = r.received;
8357
8533
  setPeer(db, url, {
8358
8534
  pull_cursor: data.cursor,
8359
8535
  push_cursor: advanceAckedPrefix(db, toPush.cursor, changes)
8360
8536
  });
8361
- 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
+ };
8362
8546
  }
8363
8547
  var changeKey = (c) => `${c.hlc}\x00${c.node_id}\x00${c.dataset}\x00${c.row_id}\x00${c.col}`;
8364
8548
  var init_client = __esm(() => {
@@ -8857,9 +9041,10 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8857
9041
  }
8858
9042
  let snapKeys = [];
8859
9043
  let pulled = 0;
9044
+ const skewed = {};
8860
9045
  if (opts.pull ?? true) {
8861
9046
  snapKeys = (await client.list(snapshotRoot(base))).map((o) => o.key).filter((k) => k.endsWith(".snap"));
8862
- pulled += await pullSnapshots(db, peerUrl, snapKeys, client, key);
9047
+ pulled += await pullSnapshots(db, peerUrl, snapKeys, client, key, skewed);
8863
9048
  for (const remote of await listRemoteNodes(client, base)) {
8864
9049
  if (remote === node)
8865
9050
  continue;
@@ -8876,7 +9061,7 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8876
9061
  if (!bytes)
8877
9062
  continue;
8878
9063
  const changes = await decodeSegment(bytes, key);
8879
- pulled += ingest(db, changes);
9064
+ pulled += collectSkew(skewed, ingestDetailed(db, changes));
8880
9065
  setStorageCursor(db, peerUrl, remote, seg.key);
8881
9066
  }
8882
9067
  }
@@ -8903,9 +9088,15 @@ async function syncWithStorage(db, peerUrl, client, config, opts = {}) {
8903
9088
  if (ownCount >= threshold)
8904
9089
  await publishSnapshot(db, client, config, opts.snapshotRetainSegments);
8905
9090
  }
8906
- return { pushed, pulled, pendingPush };
9091
+ const warnings = skewWarnings(peerUrl, skewed);
9092
+ return { pushed, pulled, pendingPush, ...warnings.length ? { warnings } : {} };
9093
+ }
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;
8907
9098
  }
8908
- async function pullSnapshots(db, peerUrl, present, client, key) {
9099
+ async function pullSnapshots(db, peerUrl, present, client, key, skewed = {}) {
8909
9100
  if (present.length === 0)
8910
9101
  return 0;
8911
9102
  const consumed = getSnapshotConsumed(db, peerUrl);
@@ -8924,7 +9115,7 @@ async function pullSnapshots(db, peerUrl, present, client, key) {
8924
9115
  if (!bytes)
8925
9116
  continue;
8926
9117
  const changes = await decodeSegment(bytes, key);
8927
- pulled += ingest(db, changes);
9118
+ pulled += collectSkew(skewed, ingestDetailed(db, changes));
8928
9119
  consumed.add(k);
8929
9120
  for (const c of changes)
8930
9121
  if (!local[c.node_id] || c.hlc > local[c.node_id])
@@ -8945,6 +9136,7 @@ var init_storage = __esm(() => {
8945
9136
  init_crdt();
8946
9137
  init_publisher_lease();
8947
9138
  init_errors();
9139
+ init_client();
8948
9140
  init_e2ee();
8949
9141
  init_intent_retention();
8950
9142
  DEFAULT_SNAPSHOT_MAX_INTERVAL_MS = 30 * 60000;
@@ -9156,7 +9348,7 @@ var package_default;
9156
9348
  var init_package = __esm(() => {
9157
9349
  package_default = {
9158
9350
  name: "@tensorix/metahub",
9159
- version: "0.5.2",
9351
+ version: "0.5.3",
9160
9352
  description: "Metahub core library and CLI",
9161
9353
  license: "AGPL-3.0-only",
9162
9354
  type: "module",
@@ -9528,7 +9720,7 @@ function applyGuestIntent(db, policy, session, intent, opts) {
9528
9720
  kind: policy.audience,
9529
9721
  guestNode: session.guestNode
9530
9722
  };
9531
- const tx = db.transaction(() => {
9723
+ return writeTx(db, () => {
9532
9724
  const prepared = prepareIntent(db, policy, principal, intent, opts.clock);
9533
9725
  const txn = intentTxnPrefix(session.guestNode, intent.intentId) + prepared.fingerprint;
9534
9726
  const receipt = readReceipt(db, session.guestNode, intent.intentId, acceptedNow);
@@ -9548,7 +9740,6 @@ function applyGuestIntent(db, policy, session, intent, opts) {
9548
9740
  return applySubmitted(db, policy, principal, intent, prepared, txn, acceptedNow);
9549
9741
  return applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow);
9550
9742
  });
9551
- return tx();
9552
9743
  }
9553
9744
  function prepareIntent(db, policy, principal, intent, clock) {
9554
9745
  if (typeof intent.payload !== "object" || intent.payload === null || Array.isArray(intent.payload))
@@ -9586,7 +9777,7 @@ function prepareIntent(db, policy, principal, intent, clock) {
9586
9777
  };
9587
9778
  }
9588
9779
  function applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow) {
9589
- return withTxnId(txn, () => withNodeId(principal.guestNode, () => {
9780
+ return withTxnId(db, txn, () => withNodeId(principal.guestNode, () => {
9590
9781
  if (intent.action === "createRecord")
9591
9782
  assertGuestCreateCapacity(db, prepared.database, policy.limits);
9592
9783
  const current = intent.action === "createRecord" ? createRecordPrepared(db, prepared.database, prepared.cells) : updateRecordPrepared(db, intent.recordId, prepared.cells);
@@ -9710,7 +9901,7 @@ function isRecordRow(value) {
9710
9901
  }
9711
9902
  function clampMillis(submittedAt, now) {
9712
9903
  const safeNow = Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
9713
- const ceil = safeNow + HLC_SKEW_MS;
9904
+ const ceil = safeNow + HLC_MAX_SKEW_MS;
9714
9905
  if (!Number.isFinite(submittedAt))
9715
9906
  return safeNow;
9716
9907
  return Math.max(0, Math.min(Math.trunc(submittedAt), ceil));
@@ -9749,16 +9940,16 @@ function stableStringify(value) {
9749
9940
  }
9750
9941
  return JSON.stringify(value) ?? "null";
9751
9942
  }
9752
- var HLC_SKEW_MS, INTENT_ID_RE, RECEIPT_COL = "result";
9943
+ var INTENT_ID_RE, RECEIPT_COL = "result";
9753
9944
  var init_guest_intent = __esm(() => {
9754
9945
  init_errors();
9755
9946
  init_records();
9947
+ init_hlc();
9756
9948
  init_crdt();
9757
9949
  init_ids();
9758
9950
  init_intent_retention();
9759
9951
  init_grants_core();
9760
9952
  init_intent_retention();
9761
- HLC_SKEW_MS = 5 * 60000;
9762
9953
  INTENT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
9763
9954
  });
9764
9955
 
@@ -9766,6 +9957,7 @@ var init_guest_intent = __esm(() => {
9766
9957
  var ROOM_PROTOCOL_VERSION = 1, ROOM_BLOB_CHUNK_LIMIT, STATE_TABLE;
9767
9958
  var init_room_protocol = __esm(() => {
9768
9959
  init_errors();
9960
+ init_history_cache();
9769
9961
  init_crdt();
9770
9962
  init_schema_init();
9771
9963
  init_ids();
@@ -9867,13 +10059,13 @@ async function syncWithRoom(db, cfg, transport, opts = {}) {
9867
10059
  }
9868
10060
  assertGuestAuthored(resp.changes, cfg.guestBase);
9869
10061
  const pulled = ingest(db, resp.changes);
9870
- db.transaction(() => {
10062
+ writeTx(db, () => {
9871
10063
  applyPartitionDiff(db, cfg.peerKey, {
9872
10064
  entered: [...prep.shipped, ...pulledMemberRows(db, cfg, resp.changes)],
9873
10065
  left: prep.diff.left
9874
10066
  });
9875
10067
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: prep.pushCursor });
9876
- })();
10068
+ });
9877
10069
  if (resp.need_baseline.length > 0)
9878
10070
  await healBaselines(db, cfg, transport, resp.need_baseline);
9879
10071
  let healed = false;
@@ -9909,14 +10101,14 @@ async function healBaselines(db, cfg, transport, keys) {
9909
10101
  const resp = await roundTrip(db, cfg, transport, changes, evict);
9910
10102
  if (resp.share_state === "expired")
9911
10103
  return;
9912
- db.transaction(() => {
10104
+ writeTx(db, () => {
9913
10105
  applyPartitionDiff(db, cfg.peerKey, {
9914
10106
  entered: [...entered, ...pulledMemberRows(db, cfg, resp.changes)],
9915
10107
  left: evict
9916
10108
  });
9917
10109
  const cur = readCursors(db, cfg.peerKey);
9918
10110
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: cur.push });
9919
- })();
10111
+ });
9920
10112
  }
9921
10113
  async function fullReconcile(db, cfg, transport) {
9922
10114
  const members = computePartitionMembers(db, cfg.scope);
@@ -9924,11 +10116,11 @@ async function fullReconcile(db, cfg, transport) {
9924
10116
  const resp = await roundTrip(db, cfg, transport, winners, [], members);
9925
10117
  if (resp.share_state === "expired")
9926
10118
  return;
9927
- db.transaction(() => {
10119
+ writeTx(db, () => {
9928
10120
  resetPartitionShadow(db, cfg.peerKey, members);
9929
10121
  const cur = readCursors(db, cfg.peerKey);
9930
10122
  writeCursors(db, cfg.peerKey, { pull: resp.cursor, push: cur.push });
9931
- })();
10123
+ });
9932
10124
  }
9933
10125
  async function roundTrip(db, cfg, transport, changes, evict, members) {
9934
10126
  const resp = await transport({
@@ -10330,7 +10522,7 @@ function updatePublicSiteChannelUrls(db, siteId, siteName) {
10330
10522
  return changed;
10331
10523
  }
10332
10524
  function applySiteUpdate(db, siteId, opts, hooks) {
10333
- return db.transaction(() => withChangeGroup("site.update", () => {
10525
+ return withChangeGroup(db, "site.update", () => {
10334
10526
  const updated = updateSite(db, siteId, {
10335
10527
  ...opts,
10336
10528
  visibility: opts.visibility === "public" ? "private" : opts.visibility
@@ -10342,15 +10534,15 @@ function applySiteUpdate(db, siteId, opts, hooks) {
10342
10534
  if (opts.name !== undefined)
10343
10535
  updatePublicSiteChannelUrls(db, siteId, updated.name);
10344
10536
  return getSite(db, siteId);
10345
- }))();
10537
+ });
10346
10538
  }
10347
10539
  function applySiteDelete(db, siteId) {
10348
- return db.transaction(() => withChangeGroup("site.delete", () => {
10540
+ return withChangeGroup(db, "site.delete", () => {
10349
10541
  if (!deleteSite(db, siteId))
10350
10542
  return false;
10351
10543
  revokeAllSiteChannels(db, siteId);
10352
10544
  return true;
10353
- }))();
10545
+ });
10354
10546
  }
10355
10547
  function revokeAllSiteChannels(db, siteId) {
10356
10548
  const active = listSiteChannelRows(db, siteId).filter((channel) => channel.desired_state === "active");
@@ -10475,7 +10667,7 @@ var init_site_channel_store = __esm(() => {
10475
10667
  policy_json: input.policy == null ? null : JSON.stringify(input.policy),
10476
10668
  desired_state: input.desiredState ?? "active"
10477
10669
  };
10478
- db.transaction(() => {
10670
+ writeTx(db, () => {
10479
10671
  if (!existing) {
10480
10672
  const first = emit(db, "site_channels", id, "site_id", input.siteId);
10481
10673
  emit(db, "site_channels", id, "created_hlc", first.hlc);
@@ -10485,7 +10677,7 @@ var init_site_channel_store = __esm(() => {
10485
10677
  continue;
10486
10678
  emit(db, "site_channels", id, col, value);
10487
10679
  }
10488
- })();
10680
+ });
10489
10681
  return getSiteChannelRow(db, id);
10490
10682
  });
10491
10683
  setSiteChannelDesiredState = grouped(function setSiteChannelDesiredState(db, id, desired) {
@@ -10763,14 +10955,13 @@ async function rotateStoragePeer(db, url, input) {
10763
10955
  };
10764
10956
  }
10765
10957
  function removePeer(db, url) {
10766
- const tx = db.transaction(() => {
10958
+ return writeTx(db, () => {
10767
10959
  const changed = db.query("DELETE FROM peers WHERE url = ?").run(url).changes > 0;
10768
10960
  db.query("DELETE FROM peer_grants WHERE peer_url = ?").run(url);
10769
10961
  db.query("DELETE FROM storage_cursors WHERE peer_url = ?").run(url);
10770
10962
  db.query("DELETE FROM room_rows WHERE peer_key = ?").run(url);
10771
10963
  return changed;
10772
10964
  });
10773
- return tx();
10774
10965
  }
10775
10966
  function setPeerEnabled(db, url, enabled) {
10776
10967
  return db.query("UPDATE peers SET enabled = ? WHERE url = ?").run(enabled ? 1 : 0, url).changes > 0;
@@ -10845,7 +11036,7 @@ async function syncPeerOnce(db, url, opts) {
10845
11036
  result = await syncWithPeer(db, url, { timeoutMs: opts?.timeoutMs });
10846
11037
  }
10847
11038
  updatePeerStatus(db, url, "ok", null);
10848
- const warnings = [];
11039
+ const warnings = [...result.warnings ?? []];
10849
11040
  try {
10850
11041
  if (peer?.kind !== "s3" && peer?.kind !== "room")
10851
11042
  clearCoveredSiteRollbacks(db, url);
@@ -11014,20 +11205,20 @@ function normalizePublicBaseUrl(input) {
11014
11205
  try {
11015
11206
  parsed = new URL(input.trim());
11016
11207
  } catch {
11017
- 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");
11018
11209
  }
11019
11210
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
11020
- 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");
11021
11212
  if (parsed.username || parsed.password || parsed.search || parsed.hash)
11022
- 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");
11023
11214
  if (parsed.pathname !== "/" && parsed.pathname !== "")
11024
- 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");
11025
11216
  const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
11026
11217
  const scope = hostScope(host);
11027
11218
  if (scope === "invalid")
11028
- 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");
11029
11220
  if (parsed.protocol === "http:" && scope === "public")
11030
- 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");
11031
11222
  parsed.pathname = "";
11032
11223
  const url = parsed.toString().replace(/\/+$/, "");
11033
11224
  return { url, scope };
@@ -11075,6 +11266,7 @@ function setServerConfig(db, partial) {
11075
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;
11076
11267
  var init_config = __esm(() => {
11077
11268
  init_token();
11269
+ init_errors();
11078
11270
  MAX_BLOB_UPLOAD_BYTES = Number(process.env.METAHUB_MAX_BLOB_UPLOAD) || 100 * 1024 * 1024;
11079
11271
  DEFAULT_CONFIG = {
11080
11272
  host: "127.0.0.1",
@@ -12099,6 +12291,8 @@ function redeemPairingCode(db, code) {
12099
12291
  }
12100
12292
  function mintGrant(db, peerUrl, nodeId) {
12101
12293
  const token = randomSuffix(32);
12294
+ if (nodeId)
12295
+ db.query("DELETE FROM peer_grants WHERE node_id = ?").run(nodeId);
12102
12296
  db.query("INSERT INTO peer_grants (token, peer_url, node_id, created_at) VALUES (?, ?, ?, ?)").run(token, peerUrl, nodeId, Date.now());
12103
12297
  return token;
12104
12298
  }
@@ -12433,6 +12627,7 @@ async function refreshBucketPresence(db, url) {
12433
12627
  }
12434
12628
  var init_devices = __esm(() => {
12435
12629
  init_errors();
12630
+ init_hlc();
12436
12631
  init_node();
12437
12632
  init_peers();
12438
12633
  init_pairing();
@@ -12979,12 +13174,171 @@ var init_blob_routes = __esm(() => {
12979
13174
  ];
12980
13175
  });
12981
13176
 
12982
- // src/core/sync/routes.ts
12983
- 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);
12984
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);
12985
13338
  const code = errorCode(e);
12986
13339
  return Response.json(code ? { error: message, code } : { error: message }, {
12987
- status: code ? HTTP_STATUS[code] : 400
13340
+ status: code ? HTTP_STATUS[code] : 400,
13341
+ ...code === "busy" ? { headers: { "retry-after": "1" } } : {}
12988
13342
  });
12989
13343
  }
12990
13344
  var HTTP_STATUS, syncRoutes, routes;
@@ -12995,6 +13349,7 @@ var init_routes = __esm(() => {
12995
13349
  init_peers_routes();
12996
13350
  init_blob_routes();
12997
13351
  init_errors();
13352
+ init_page_messages();
12998
13353
  init_package();
12999
13354
  HTTP_STATUS = {
13000
13355
  invalid_input: 400,
@@ -13005,6 +13360,8 @@ var init_routes = __esm(() => {
13005
13360
  auth: 401,
13006
13361
  network: 502,
13007
13362
  rate_limited: 429,
13363
+ busy: 503,
13364
+ clock_skew: 409,
13008
13365
  port_in_use: 500
13009
13366
  };
13010
13367
  syncRoutes = [
@@ -13016,12 +13373,18 @@ var init_routes = __esm(() => {
13016
13373
  response: SyncResponseSchema,
13017
13374
  async handler(req, { db, node }) {
13018
13375
  const body = await req.json();
13019
- ingest(db, body.changes ?? []);
13376
+ const r = ingestDetailed(db, body.changes ?? []);
13377
+ const skewed = Object.values(r.skewed).reduce((a, b) => a + b, 0);
13020
13378
  const batch = changesAfterSeq(db, body.since ?? 0, {
13021
13379
  limit: body.limit,
13022
13380
  excludeDatasets: body.exclude_datasets
13023
13381
  });
13024
- 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
+ });
13025
13388
  }
13026
13389
  },
13027
13390
  {
@@ -13229,12 +13592,22 @@ function renewToken(req, url, cfg) {
13229
13592
  function wantsHtml(req) {
13230
13593
  return req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html");
13231
13594
  }
13232
- function unlockPage() {
13233
- 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>
13234
13607
  <meta charset="utf-8">
13235
13608
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
13236
13609
  <meta name="theme-color" content="#ffffff">
13237
- <title>\u9A8C\u8BC1\u8BBF\u95EE\u4EE4\u724C \xB7 metahub</title>
13610
+ <title>${m("\u9A8C\u8BC1\u8BBF\u95EE\u4EE4\u724C \xB7 metahub")}</title>
13238
13611
  <script>
13239
13612
  // FOUC-guard theme resolution, same key/logic as src/webui/theme.ts.
13240
13613
  try {
@@ -13248,18 +13621,18 @@ function unlockPage() {
13248
13621
  <div class="boot" id="boot"><svg viewBox="0 0 24 24">${CUBE_SVG}</svg></div>
13249
13622
  <form class="card" id="f" style="display:none" autocomplete="on">
13250
13623
  <div class="brand"><svg viewBox="0 0 24 24">${CUBE_SVG}</svg></div>
13251
- <h1>\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C</h1>
13252
- <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>
13253
13626
  <div class="field">
13254
- <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")}"
13255
13628
  autocomplete="current-password" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
13256
- <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>
13257
13630
  </div>
13258
13631
  <p class="err" id="e" style="display:none"></p>
13259
- <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>
13260
13633
  </form>
13261
13634
  <script>
13262
- var KEY = "mh_token";
13635
+ var KEY = "mh_token", L = ${L};
13263
13636
  var form = document.getElementById("f"), boot = document.getElementById("boot");
13264
13637
  var input = document.getElementById("t"), errEl = document.getElementById("e");
13265
13638
  var goBtn = document.getElementById("go"), peek = document.getElementById("peek");
@@ -13284,7 +13657,7 @@ function unlockPage() {
13284
13657
  }
13285
13658
  function fail(msg) {
13286
13659
  goBtn.disabled = false;
13287
- goBtn.innerHTML = "\u89E3\u9501\u5E76\u8FDB\u5165";
13660
+ goBtn.textContent = L.go;
13288
13661
  errEl.textContent = msg;
13289
13662
  errEl.style.display = "flex";
13290
13663
  form.classList.remove("bad");
@@ -13297,7 +13670,7 @@ function unlockPage() {
13297
13670
  var pw = input.type === "password";
13298
13671
  input.type = pw ? "text" : "password";
13299
13672
  peek.innerHTML = '<svg viewBox="0 0 24 24">' + (pw ? ${JSON.stringify(EYE_OFF_SVG)} : ${JSON.stringify(EYE_SVG)}) + "</svg>";
13300
- peek.setAttribute("aria-label", pw ? "\u9690\u85CF\u4EE4\u724C" : "\u663E\u793A\u4EE4\u724C");
13673
+ peek.setAttribute("aria-label", pw ? L.hide : L.show);
13301
13674
  input.focus();
13302
13675
  });
13303
13676
  input.addEventListener("input", function () {
@@ -13307,9 +13680,9 @@ function unlockPage() {
13307
13680
  form.addEventListener("submit", function (ev) {
13308
13681
  ev.preventDefault();
13309
13682
  var token = normalize(input.value);
13310
- if (!token) { fail("\u8BF7\u8F93\u5165\u8BBF\u95EE\u4EE4\u724C\u3002"); return; }
13683
+ if (!token) { fail(L.empty); return; }
13311
13684
  goBtn.disabled = true;
13312
- goBtn.innerHTML = '<span class="spin"></span>\u9A8C\u8BC1\u4E2D\u2026';
13685
+ goBtn.innerHTML = '<span class="spin"></span>' + L.verifying;
13313
13686
  var hdr = { headers: { authorization: "Bearer " + token } };
13314
13687
  // Prefer the renewal endpoint (managed mode returns the canonical token,
13315
13688
  // also upgrading a pasted in-grace token). Fall back to a cheap
@@ -13320,7 +13693,7 @@ function unlockPage() {
13320
13693
  .then(function (d) {
13321
13694
  if (d && d.token) { save(d.token); location.reload(); return; }
13322
13695
  return fetch("/api/version", hdr).then(function (r) {
13323
- 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; }
13324
13697
  // 200 (static-token mode) or no UI (404) \u2192 trust it and reload.
13325
13698
  save(token); location.reload();
13326
13699
  });
@@ -13337,7 +13710,7 @@ function unlockPage() {
13337
13710
  .then(function (r) { return r.ok ? r.json() : null; })
13338
13711
  .then(function (d) {
13339
13712
  if (d && d.token) { save(d.token); location.reload(); }
13340
- else showForm("\u767B\u5F55\u5DF2\u8FC7\u671F,\u8BF7\u91CD\u65B0\u8F93\u5165\u4EE4\u724C\u3002");
13713
+ else showForm(L.expired);
13341
13714
  })
13342
13715
  .catch(function () { showForm(""); });
13343
13716
  } else {
@@ -13465,8 +13838,9 @@ var COOKIE = "mh_token", COOKIE_ATTRS = "path=/; SameSite=Strict; Max-Age=315360
13465
13838
  @keyframes spin{to{transform:rotate(360deg)}}
13466
13839
  @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important}
13467
13840
  .brand path{stroke-dashoffset:0}}
13468
- `;
13841
+ `, escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13469
13842
  var init_auth = __esm(() => {
13843
+ init_page_messages();
13470
13844
  init_inject_runtime();
13471
13845
  init_token();
13472
13846
  init_pairing();
@@ -13496,7 +13870,9 @@ var init_http_status = __esm(() => {
13496
13870
  stale: 409,
13497
13871
  conflict: 409,
13498
13872
  auth: 401,
13499
- rate_limited: 429
13873
+ rate_limited: 429,
13874
+ busy: 503,
13875
+ clock_skew: 409
13500
13876
  };
13501
13877
  });
13502
13878
 
@@ -14275,7 +14651,7 @@ async function serveSite(req, ctx, auth, opts = {}) {
14275
14651
  const ambientOnly = mutating && authActive(auth) && explicitToken(req, url) == null;
14276
14652
  if (!ambientOnly && hasValidToken(req, url, auth)) {
14277
14653
  if (!site)
14278
- return notFoundResponse("\u7AD9\u70B9\u4E0D\u5B58\u5728", `\u6CA1\u6709\u540D\u4E3A \u201C${escapeHtml(name)}\u201D \u7684\u7AD9\u70B9\u3002`);
14654
+ return siteNotFound(req, name);
14279
14655
  if (!opts.forwardApi)
14280
14656
  return new Response("not found", { status: 404 });
14281
14657
  const fwd = new Request(`${url.origin}/${filePath}${url.search}`, req);
@@ -14302,17 +14678,18 @@ async function serveSite(req, ctx, auth, opts = {}) {
14302
14678
  return serveSiteFile(req, ctx.db, site.id, filePath, { spa: site.spa === 1, isPublic: true });
14303
14679
  }
14304
14680
  if (!hasValidToken(req, url, auth)) {
14305
- 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();
14306
14682
  }
14307
14683
  if (!site)
14308
- return notFoundResponse("\u7AD9\u70B9\u4E0D\u5B58\u5728", `\u6CA1\u6709\u540D\u4E3A \u201C${escapeHtml(name)}\u201D \u7684\u7AD9\u70B9\u3002`);
14684
+ return siteNotFound(req, name);
14309
14685
  const res = await serveSiteFile(req, ctx.db, site.id, filePath, { spa: site.spa === 1 });
14310
14686
  return withShim(res, auth, req, url);
14311
14687
  }
14312
14688
  async function serveSiteFile(req, db, siteId, path, opts = {}) {
14689
+ const locale = pickLocale(req);
14313
14690
  const meta = getFileMetaForServe(db, siteId, path, { spa: opts.spa });
14314
14691
  if (!meta)
14315
- 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"));
14316
14693
  const cacheControl = siteCacheControl(meta.row.content_type, meta.row.encoding, opts.isPublic ?? false);
14317
14694
  const headers = { etag: meta.etag, "cache-control": cacheControl };
14318
14695
  if (etagMatches(req.headers.get("if-none-match"), meta.etag)) {
@@ -14320,7 +14697,7 @@ async function serveSiteFile(req, db, siteId, path, opts = {}) {
14320
14697
  }
14321
14698
  const bytes = await decodeFileRow(db, meta.row);
14322
14699
  if (!bytes) {
14323
- 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"));
14324
14701
  }
14325
14702
  const body = new Uint8Array(bytes.byteLength);
14326
14703
  body.set(bytes);
@@ -14345,8 +14722,12 @@ function etagMatches(header, etag) {
14345
14722
  }
14346
14723
  return false;
14347
14724
  }
14348
- function notFoundResponse(title, detail) {
14349
- 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>
14350
14731
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
14351
14732
  <meta name="robots" content="noindex">
14352
14733
  <title>${escapeHtml(title)}</title>
@@ -14367,8 +14748,8 @@ function notFoundResponse(title, detail) {
14367
14748
  <p class="code">404</p>
14368
14749
  <h1>${escapeHtml(title)}</h1>
14369
14750
  <p>${detail}</p>
14370
- <a class="home" href="./">\u8FD4\u56DE\u7AD9\u70B9\u9996\u9875</a>
14371
- <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>
14372
14753
  </main></body></html>`;
14373
14754
  return new Response(html, {
14374
14755
  status: 404,
@@ -14383,6 +14764,7 @@ var init_sites_serve = __esm(() => {
14383
14764
  init_grants_routes();
14384
14765
  init_rate_limit();
14385
14766
  init_share_render();
14767
+ init_page_messages();
14386
14768
  init_auth();
14387
14769
  });
14388
14770
 
@@ -14437,6 +14819,51 @@ async function mintGuestSession(scope, opts) {
14437
14819
  return { sub: opts.sub, exp, cookie };
14438
14820
  }
14439
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
+
14440
14867
  // src/core/sync/share-serve.ts
14441
14868
  function notFound() {
14442
14869
  return new Response("not found", { status: 404 });
@@ -14467,25 +14894,26 @@ async function serveShare(req, ctx, opts = {}) {
14467
14894
  return notFound();
14468
14895
  if (share.transport !== "server")
14469
14896
  return notFound();
14897
+ const locale = pickLocale(req);
14470
14898
  if (shareExpired(share))
14471
14899
  return gone();
14472
14900
  if (sub === "unlock" && req.method === "POST")
14473
14901
  return handleUnlock(ctx, req, share, url);
14474
14902
  const locked = !!share.pw_hash && !await readShareSession(ctx, req, share);
14475
14903
  if (locked) {
14476
- return wantsHtml2(req) ? new Response(passwordPage(share, false), { headers: HTML }) : unauthorizedJson();
14904
+ return wantsHtml2(req) ? new Response(passwordPage(locale, share, false), { headers: HTML }) : unauthorizedJson();
14477
14905
  }
14478
14906
  if (sub.startsWith("blob/"))
14479
14907
  return serveScopedBlob(ctx, share, sub.slice("blob/".length));
14480
14908
  if (share.kind === "doc")
14481
- return serveDoc(ctx, req, share, sub);
14909
+ return serveDoc(ctx, req, share, sub, locale);
14482
14910
  if (share.kind === "database")
14483
- return serveTable(ctx, req, share, sub);
14911
+ return serveTable(ctx, req, share, sub, locale);
14484
14912
  if (share.kind === "site")
14485
- return serveSiteShare(ctx, req, share, sub, opts);
14913
+ return serveSiteShare(ctx, req, share, sub, opts, locale);
14486
14914
  return notFound();
14487
14915
  }
14488
- async function serveDoc(ctx, req, share, sub) {
14916
+ async function serveDoc(ctx, req, share, sub, locale) {
14489
14917
  const doc = getDocument(ctx.db, share.target_id);
14490
14918
  if (!doc)
14491
14919
  return notFound();
@@ -14514,26 +14942,27 @@ async function serveDoc(ctx, req, share, sub) {
14514
14942
  });
14515
14943
  const version = documentVersion(ctx.db, share.target_id);
14516
14944
  const editable = share.permission === "edit";
14517
- const inner = `<article class="doc">${rendered || '<p class="muted">\uFF08\u7A7A\u6587\u6863\uFF09</p>'}</article>`;
14518
- const script = editable ? docEditScript(share.slug, version, doc.body ?? "") : "";
14519
- 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 });
14520
14948
  }
14521
- 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") });
14522
14951
  return `
14523
- 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};
14524
14953
  const art=document.querySelector('article.doc');
14525
14954
  const btn=document.getElementById('mh-edit');
14526
14955
  let ta;
14527
14956
  btn.addEventListener('click', async ()=>{
14528
14957
  if(!editing){
14529
- editing=true; btn.textContent='\u4FDD\u5B58';
14958
+ editing=true; btn.textContent=L.save;
14530
14959
  ta=document.createElement('textarea'); ta.className='mh-raw'; ta.value=body0;
14531
14960
  art.replaceWith(ta); ta.focus();
14532
14961
  } else {
14533
- btn.disabled=true; btn.textContent='\u4FDD\u5B58\u4E2D\u2026';
14962
+ btn.disabled=true; btn.textContent=L.saving;
14534
14963
  const res=await fetch('/share/${slug}/doc',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({body:ta.value,ifMatch:ver})});
14535
14964
  if(res.ok){ location.reload(); }
14536
- 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; }
14537
14966
  }
14538
14967
  });`;
14539
14968
  }
@@ -14562,7 +14991,7 @@ function cellHtml(prop, value, titles) {
14562
14991
  function editableType(t) {
14563
14992
  return t === "text" || t === "number" || t === "url" || t === "date";
14564
14993
  }
14565
- async function serveTable(ctx, req, share, sub) {
14994
+ async function serveTable(ctx, req, share, sub, locale) {
14566
14995
  const dbRow = getDatabase(ctx.db, share.target_id);
14567
14996
  if (!dbRow)
14568
14997
  return notFound();
@@ -14613,13 +15042,18 @@ async function serveTable(ctx, req, share, sub) {
14613
15042
  return `<tr>${tds}</tr>`;
14614
15043
  }).join("");
14615
15044
  const inner = `<div class="table-wrap"><table class="db"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>`;
14616
- const script = editable ? tableEditScript(share.slug) : "";
14617
- 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" : "" }), {
14618
- headers: HTML
14619
- });
14620
- }
14621
- 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) {
14622
15055
  return `
15056
+ const L=${JSON.stringify({ failed: pm(locale, "\u4FDD\u5B58\u5931\u8D25") })};
14623
15057
  document.querySelectorAll('td[contenteditable]').forEach(td=>{
14624
15058
  let orig=td.textContent;
14625
15059
  td.addEventListener('blur', async ()=>{
@@ -14631,11 +15065,11 @@ function tableEditScript(slug) {
14631
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})});
14632
15066
  td.classList.remove('saving');
14633
15067
  if(res.ok){ orig=val; td.classList.add('saved'); setTimeout(()=>td.classList.remove('saved'),600); }
14634
- 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); }
14635
15069
  });
14636
15070
  });`;
14637
15071
  }
14638
- async function serveSiteShare(ctx, req, share, sub, opts = {}) {
15072
+ async function serveSiteShare(ctx, req, share, sub, opts = {}, locale = "zh-CN") {
14639
15073
  let site;
14640
15074
  try {
14641
15075
  site = resolveSite(ctx.db, share.target_id);
@@ -14658,7 +15092,7 @@ async function serveSiteShare(ctx, req, share, sub, opts = {}) {
14658
15092
  }
14659
15093
  if (share.permission === "edit") {
14660
15094
  if (sub === "_files")
14661
- return new Response(siteFilesPage(ctx, share, siteId), { headers: HTML });
15095
+ return new Response(siteFilesPage(ctx, share, siteId, locale), { headers: HTML });
14662
15096
  if (sub === "_file" && req.method === "POST") {
14663
15097
  const body = await req.json().catch(() => ({}));
14664
15098
  if (!body.path || typeof body.content !== "string")
@@ -14690,34 +15124,35 @@ async function serveSiteShare(ctx, req, share, sub, opts = {}) {
14690
15124
  }
14691
15125
  return serveSiteFile(req, ctx.db, siteId, sub, { spa: site.spa === 1 });
14692
15126
  }
14693
- function siteFilesPage(ctx, share, siteId) {
15127
+ function siteFilesPage(ctx, share, siteId, locale) {
14694
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") });
14695
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("");
14696
15131
  const inner = `
14697
- <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>
14698
- <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>
14699
15134
  <div id="editor" hidden>
14700
- <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>
14701
15136
  <textarea class="mh-raw" id="fbody"></textarea>
14702
- <div class="row"><button id="save">\u4FDD\u5B58</button></div>
15137
+ <div class="row"><button id="save">${escapeHtml(pm(locale, "\u4FDD\u5B58"))}</button></div>
14703
15138
  </div>`;
14704
15139
  const script = `
14705
- const slug=${JSON.stringify(share.slug)};
15140
+ const slug=${JSON.stringify(share.slug)}; const L=${L};
14706
15141
  const ed=document.getElementById('editor'), fpath=document.getElementById('fpath'), fbody=document.getElementById('fbody'), open=document.getElementById('open');
14707
15142
  let cur=null;
14708
15143
  document.querySelectorAll('button.file').forEach(b=>b.addEventListener('click', async ()=>{
14709
15144
  cur=b.dataset.path; fpath.textContent=cur; open.href='/share/'+slug+'/'+cur;
14710
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; }
14711
- 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; }
14712
15147
  ed.hidden=false;
14713
15148
  }));
14714
15149
  document.getElementById('save').addEventListener('click', async ()=>{
14715
15150
  if(!cur||fbody.disabled) return;
14716
15151
  const res=await fetch('/share/'+slug+'/_file',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:cur,content:fbody.value})});
14717
- if(res.ok){ const s=document.getElementById('save'); s.textContent='\u5DF2\u4FDD\u5B58 \u2713'; setTimeout(()=>s.textContent='\u4FDD\u5B58',900); }
14718
- 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); }
14719
15154
  });`;
14720
- 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 });
14721
15156
  }
14722
15157
  function referencedHashes2(ctx, share) {
14723
15158
  const out = new Set;
@@ -14805,7 +15240,7 @@ async function handleUnlock(ctx, req, share, url) {
14805
15240
  pw = form ? String(form.get("password") ?? "") : "";
14806
15241
  }
14807
15242
  if (!await verifySharePassword(share, pw)) {
14808
- return new Response(passwordPage(share, true), { headers: HTML, status: 401 });
15243
+ return new Response(passwordPage(pickLocale(req), share, true), { headers: HTML, status: 401 });
14809
15244
  }
14810
15245
  const { cookie } = await mintShareSession(ctx, share, url);
14811
15246
  return new Response(null, {
@@ -14816,21 +15251,23 @@ async function handleUnlock(ctx, req, share, url) {
14816
15251
  function ttlRemaining(share) {
14817
15252
  return share.expires_at == null ? SHARE_SESSION_TTL_MS : Math.max(0, share.expires_at - Date.now());
14818
15253
  }
14819
- function passwordPage(share, error) {
15254
+ function passwordPage(locale, share, error) {
14820
15255
  const inner = `
14821
15256
  <form method="post" action="/share/${share.slug}/unlock" class="pw">
14822
- <h1>\uD83D\uDD12 \u6B64\u5206\u4EAB\u53D7\u53E3\u4EE4\u4FDD\u62A4</h1>
14823
- ${error ? '<p class="err">\u53E3\u4EE4\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002</p>' : ""}
14824
- <input type="password" name="password" placeholder="\u53E3\u4EE4" autofocus autocomplete="current-password">
14825
- <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>
14826
15261
  </form>`;
14827
- 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 });
14828
15263
  }
14829
- function pageShell(title, inner, opts = {}) {
14830
- 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>` : "";
14831
15268
  const hint = opts.hint ? `<p class="hint">${escapeHtml(opts.hint)}</p>` : "";
14832
- const script = opts.script ? `<script>(function(){${opts.script}})();</script>` : "";
14833
- 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>
14834
15271
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
14835
15272
  <meta name="robots" content="noindex">
14836
15273
  <title>${escapeHtml(title)}</title>
@@ -14842,7 +15279,8 @@ function pageShell(title, inner, opts = {}) {
14842
15279
  .wrap{max-width:820px;margin:0 auto;padding:32px 20px 80px}
14843
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}
14844
15281
  header.mh h1.title{font-size:22px;margin:0}
14845
- .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}
14846
15284
  .hint{color:var(--muted);font-size:13px;margin:0 0 14px}
14847
15285
  article.doc h1,article.doc h2,article.doc h3{line-height:1.3;margin:1.4em 0 .5em}
14848
15286
  article.doc h1{font-size:1.7em} article.doc h2{font-size:1.4em} article.doc h3{font-size:1.2em}
@@ -14873,10 +15311,10 @@ function pageShell(title, inner, opts = {}) {
14873
15311
  footer.mh{margin-top:48px;border-top:1px solid var(--line);padding-top:14px;color:var(--muted);font-size:12px;text-align:center}
14874
15312
  </style></head><body>
14875
15313
  ${opts.bare ? `<div class="wrap">${inner}</div>` : `<div class="wrap">
14876
- <header class="mh"><h1 class="title">${escapeHtml(title)}</h1>${editBtn}</header>
15314
+ <header class="mh"><h1 class="title">${escapeHtml(title)}</h1>${tools}</header>
14877
15315
  ${hint}
14878
15316
  ${inner}
14879
- <footer class="mh">\u901A\u8FC7 metahub \u5206\u4EAB</footer>
15317
+ <footer class="mh">${escapeHtml(pm(opts.lang, "\u901A\u8FC7 metahub \u5206\u4EAB"))}</footer>
14880
15318
  </div>`}
14881
15319
  ${script}
14882
15320
  </body></html>`;
@@ -14899,6 +15337,8 @@ var init_share_serve = __esm(() => {
14899
15337
  init_grants_routes();
14900
15338
  init_rate_limit();
14901
15339
  init_share_render();
15340
+ init_share_copy();
15341
+ init_page_messages();
14902
15342
  HTML = { "content-type": "text/html; charset=utf-8" };
14903
15343
  HASH_RE2 = /^[0-9a-f]{16,64}$/;
14904
15344
  SHARE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
@@ -15275,11 +15715,12 @@ var DROP_ENVELOPE_MAX_BYTES, DROP_HLC_SKEW_MS, GUEST_NODE_RE;
15275
15715
  var init_drop_protocol = __esm(() => {
15276
15716
  init_errors();
15277
15717
  init_ids();
15718
+ init_hlc();
15278
15719
  init_grants_core();
15279
15720
  init_e2ee();
15280
15721
  init_seal();
15281
15722
  DROP_ENVELOPE_MAX_BYTES = 64 * 1024;
15282
- DROP_HLC_SKEW_MS = 5 * 60000;
15723
+ DROP_HLC_SKEW_MS = HLC_MAX_SKEW_MS;
15283
15724
  GUEST_NODE_RE = /^g[0-9a-z]{8}$/;
15284
15725
  });
15285
15726
 
@@ -15333,12 +15774,12 @@ function applyDropV2(db, set, payload, now) {
15333
15774
  };
15334
15775
  const before = db.query("SELECT MAX(seq) AS s FROM crdt_changes").get().s ?? 0;
15335
15776
  const ids = [];
15336
- db.transaction(() => {
15777
+ writeTx(db, () => {
15337
15778
  for (const intent of payload.intents) {
15338
15779
  applyGuestIntent(db, policy, { guestNode: payload.guest_node }, intent, { clock: "submitted", now });
15339
15780
  ids.push(intent.intentId);
15340
15781
  }
15341
- })();
15782
+ });
15342
15783
  const ingested = db.query("SELECT COUNT(*) AS n FROM crdt_changes WHERE seq > ?").get(before).n;
15343
15784
  return { ackSeq: intentsMaxSeq(db, payload.guest_node, ids), ingested };
15344
15785
  }
@@ -15532,6 +15973,7 @@ function appKind() {
15532
15973
  function openMetahub() {
15533
15974
  ensureDirs();
15534
15975
  const db = new Database(dbPath(), { create: true });
15976
+ db.exec("PRAGMA busy_timeout = 5000;");
15535
15977
  db.exec("PRAGMA journal_mode = WAL;");
15536
15978
  initSchema(db);
15537
15979
  const app = appKind();
@@ -15556,6 +15998,7 @@ function safeHostname() {
15556
15998
  // src/core/index.ts
15557
15999
  init_ids();
15558
16000
  init_resolve();
16001
+ init_hlc();
15559
16002
  init_crdt();
15560
16003
  init_node();
15561
16004
  init_databases();
@@ -15682,18 +16125,18 @@ function ensureIndex(db) {
15682
16125
  return false;
15683
16126
  const version = readMeta(db, "search_index_version");
15684
16127
  const seq = readMeta(db, "search_seq");
15685
- db.transaction(() => {
16128
+ writeTx(db, () => {
15686
16129
  if (version !== SEARCH_INDEX_VERSION || seq === null)
15687
16130
  fullRebuild(db);
15688
16131
  else
15689
16132
  incrementalUpdate(db);
15690
- })();
16133
+ });
15691
16134
  return true;
15692
16135
  }
15693
16136
  function rebuildSearchIndex(db) {
15694
16137
  if (!ftsAvailable(db))
15695
16138
  return false;
15696
- db.transaction(() => fullRebuild(db))();
16139
+ writeTx(db, () => fullRebuild(db));
15697
16140
  return true;
15698
16141
  }
15699
16142
  function ftsSearch(db, query, limit) {
@@ -15853,6 +16296,7 @@ function startServer(opts = {}) {
15853
16296
  allowRemoteSiteHosting: opts.allowRemoteSiteHosting ?? true
15854
16297
  };
15855
16298
  const allRoutes = opts.ui ? [...routes, ...opts.ui.routes] : routes;
16299
+ opts.ui?.onStart?.(db);
15856
16300
  const forwardApi = async (req) => {
15857
16301
  const path = new URL(req.url).pathname;
15858
16302
  const route = allRoutes.find((r) => r.method === req.method && r.path === path);
@@ -15898,7 +16342,7 @@ function startServer(opts = {}) {
15898
16342
  } else {
15899
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/");
15900
16344
  if (!exempt && !hasValidToken(req, url, auth)) {
15901
- 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();
15902
16346
  }
15903
16347
  }
15904
16348
  if (url.pathname === RENEW_PATH) {
@@ -16065,6 +16509,8 @@ var styles_default = ` :root {
16065
16509
  --fs-body:14px; --fs-ui:13.5px; --fs-sm:12.5px; --fs-xs:11.5px; --fs-micro:11px;
16066
16510
  --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
16067
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);
16068
16514
  --note-lh:1.6;
16069
16515
  --code-bg:#f8f8f6;
16070
16516
  --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
@@ -16083,6 +16529,7 @@ var styles_default = ` :root {
16083
16529
  --hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
16084
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);
16085
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);
16086
16533
  --hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
16087
16534
  --hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
16088
16535
  }
@@ -16161,12 +16608,11 @@ var styles_default = ` :root {
16161
16608
  background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
16162
16609
  display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
16163
16610
  .brand .mark svg { width:15px; height:15px; stroke:#fff; }
16164
- .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;
16165
16612
  background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
16166
- box-shadow:var(--shadow-sm); transition:border-color .15s; }
16167
- .sb-search:focus-within { border-color:var(--accent); }
16168
- .sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
16169
- .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); }
16170
16616
  .sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
16171
16617
  /* Notion-style list switcher: the active tab is an icon+label pill, inactive
16172
16618
  tabs are icon-only. One absolutely-positioned indicator slides & stretches
@@ -16380,6 +16826,9 @@ var styles_default = ` :root {
16380
16826
  .set-row-tag { display:inline-block; margin-left:8px; font-size:11px; font-weight:500;
16381
16827
  color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; vertical-align:1px; }
16382
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); }
16383
16832
 
16384
16833
  /* Page chrome (primitives.tsx PageHeader / DangerZone) \u2014 every page opens
16385
16834
  with its own PageHeader (the shell renders no title). */
@@ -16388,7 +16837,7 @@ var styles_default = ` :root {
16388
16837
  .page-header-text { flex:1 1 auto; min-width:0; }
16389
16838
  .page-header-title { font-size:26px; font-weight:700; letter-spacing:-.02em; color:var(--fg); margin:0; }
16390
16839
  .page-header-sub { color:var(--muted); font-size:var(--fs-sm); margin-top:3px; }
16391
- .page-header-action { flex:none; padding-top:4px; }
16840
+ .page-header-action { flex:none; display:flex; align-items:center; gap:8px; padding-top:4px; }
16392
16841
  .page-header-banner { margin-top:8px; }
16393
16842
  .danger-zone { margin-top:34px; }
16394
16843
  .danger-zone-label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em;
@@ -16437,11 +16886,6 @@ var styles_default = ` :root {
16437
16886
  .audit-end::before, .audit-end::after { content:""; flex:1; height:1px; background:var(--line); }
16438
16887
  @media (hover:none) { .audit-revert { opacity:1; } }
16439
16888
  .set-callout.warn { border-color:color-mix(in srgb,var(--warning,#9a6700) 32%,var(--line)); }
16440
- /* two-fact meta line under a block title (who publishes \xB7 where creds live) \u2014
16441
- answers the questions hiding under "is this bucket for server or browser?" */
16442
- .set-meta { display:flex; flex-wrap:wrap; gap:8px 16px; margin:0 0 12px; }
16443
- .set-meta-item { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--fg-soft); }
16444
- .set-meta-item svg { width:14px; height:14px; color:var(--accent); }
16445
16889
  /* a quiet helper line under a field / chip group */
16446
16890
  .set-hint { font-size:var(--fs-xs); color:var(--muted); line-height:1.5; margin:3px 0 4px; }
16447
16891
  /* progressive disclosure ("\u5DE5\u4F5C\u539F\u7406" / "\u8FDB\u9636") \u2014 native details, themed */
@@ -16526,39 +16970,18 @@ var styles_default = ` :root {
16526
16970
  .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16527
16971
  .peer-menu { flex:none; padding:6px; }
16528
16972
 
16529
- /* workspace data-map summary heading \u6570\u636E\u4E0E\u5907\u4EFD (SyncHealthLine): a quiet
16530
- page sub-line, not a callout \u2014 no border, no fill; the dot carries tone. */
16531
- .sync-health { margin:0; }
16532
- .sync-health-line { display:inline-flex; align-items:center; gap:8px; max-width:100%; padding:2px 0;
16533
- background:none; border:0; cursor:pointer; text-align:left; font:inherit; color:var(--fg-soft); }
16534
- .sync-health-line:hover { color:var(--fg); }
16535
- .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; }
16536
16975
  .sync-health-dot { flex:none; width:8px; height:8px; border-radius:50%; }
16537
16976
  .sync-health-dot.dot-ok { background:var(--success); }
16538
16977
  .sync-health-dot.dot-warn { background:var(--warning,#9a6700); }
16539
16978
  .sync-health-dot.dot-error { background:var(--danger); }
16540
- .sync-health-text { min-width:0; font-size:var(--fs-sm); font-weight:500;
16541
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16542
- .sync-health-more { flex:none; font-size:var(--fs-xs); color:var(--muted); }
16543
- /* shared expander chevron (SyncHealthLine \u8BE6\u60C5, device roster rows) */
16979
+ /* shared expander chevron (bucket + device roster rows) */
16544
16980
  .row-chev { flex:none; color:var(--muted); display:flex; transition:transform .15s; }
16545
16981
  .row-chev .ico { width:14px; height:14px; }
16546
16982
  .row-chev.open { transform:rotate(180deg); }
16547
16983
  /* confirm dialogs: keep \\n line breaks (consequence lists) */
16548
16984
  .cfm-msg { white-space: pre-line; }
16549
- /* concurrent problems under the headline (only when >1, else redundant) */
16550
- .sync-health-issues { padding:2px 0 4px; }
16551
- .sync-health-issue { font-size:12px; color:var(--muted); padding:2px 0 2px 18px; }
16552
- /* \u67E5\u770B\u5404\u5904: a bordered place list (kind \xB7 name \xB7 freshness \xB7 inline retry) */
16553
- .sync-health-places { margin-top:6px; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; }
16554
- .sync-health-place { display:flex; align-items:center; gap:10px; padding:9px 12px; min-width:0; font-size:var(--fs-sm); }
16555
- .sync-health-place + .sync-health-place { border-top:1px solid var(--line); }
16556
- .sync-health-place-kind { flex:none; width:64px; font-size:var(--fs-xs); color:var(--muted); }
16557
- .sync-health-place-label { flex:none; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:40%; }
16558
- .sync-health-place-cap { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16559
- .sync-health-place .btn { margin-left:auto; }
16560
- .sync-health-role { flex:none; font-size:11px; color:var(--accent); background:var(--accent-soft);
16561
- border-radius:999px; padding:2px 7px; }
16562
16985
 
16563
16986
  /* device roster (settings/devices-page.tsx): expanded detail = key/value
16564
16987
  table + one action row + one plain sentence \u2014 no gray callout box. */
@@ -16580,7 +17003,6 @@ var styles_default = ` :root {
16580
17003
  display:grid; grid-template-columns:1fr auto; gap:4px 12px; align-items:center; background:var(--surface-1); }
16581
17004
  .cloud-cta-title { font-weight:650; font-size:var(--fs-body); }
16582
17005
  .cloud-cta-sub { font-size:12px; color:var(--muted); margin-top:2px; line-height:1.6; }
16583
- .cloud-cta-alt { grid-column:1 / -1; font-size:12px; }
16584
17006
 
16585
17007
  /* printable recovery card */
16586
17008
  .recovery-card { border:2px solid var(--fg); border-radius:12px; padding:16px 18px; margin-top:8px; background:var(--surface); }
@@ -16627,6 +17049,8 @@ var styles_default = ` :root {
16627
17049
  .crumb-link { flex:none; padding:2px 5px; margin:-2px -3px; border-radius:var(--radius-sm); font-weight:500; }
16628
17050
  .crumb-link:hover { background:var(--hover); }
16629
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); }
16630
17054
  .iconbtn { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-sm); color:var(--fg-soft); transition:background .12s; }
16631
17055
  .iconbtn:hover { background:var(--hover-2); color:var(--fg); }
16632
17056
  /* Dead end of the history (topbar back/forward). pointer-events:none also
@@ -16720,7 +17144,7 @@ var styles_default = ` :root {
16720
17144
  /* document editor */
16721
17145
  .doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
16722
17146
  .doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
16723
- .doc-title:empty::before { content:"\u65E0\u6807\u9898"; color:var(--muted); }
17147
+ .doc-title:empty::before { content:attr(data-placeholder); color:var(--muted); }
16724
17148
  /* Backstop for the plain-text titles (see plain-edit.ts): should markup ever
16725
17149
  slip into one of these hosts, its inline font-size can't shrink the title. */
16726
17150
  .doc-title *, .db-title * { font:inherit; color:inherit; background:none; }
@@ -17210,11 +17634,16 @@ var styles_default = ` :root {
17210
17634
  .db-icon { width:38px; height:38px; border-radius:9px; background:var(--surface-2); display:grid; place-items:center; font-size:20px; }
17211
17635
  .db-title { font-size:27px; font-weight:700; letter-spacing:-.02em; outline:none; }
17212
17636
  .db-desc { color:var(--muted); font-size:13px; margin-top:2px; }
17213
- .views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; }
17214
- .view-tab { display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:var(--fs-ui); font-weight:500; color:var(--muted);
17215
- 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; }
17216
17641
  .view-tab.active { color:var(--fg); border-bottom-color:var(--accent); }
17217
- .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); }
17218
17647
  /* \u6D6E\u7A97\u770B\u677F (right end of the toolbar / board bar, desktop only): a quiet
17219
17648
  Notion-style secondary pill \u2014 visible as a feature entry, never louder
17220
17649
  than \u65B0\u5EFA. Hover lifts the pill and nudges the icon's top kanban card up
@@ -17230,12 +17659,56 @@ var styles_default = ` :root {
17230
17659
  .popout-btn:active { transform:translateY(0); box-shadow:var(--shadow-sm); }
17231
17660
  .popout-btn .i-card { transition:transform .18s ease; }
17232
17661
  .popout-btn:hover .i-card { transform:translateY(-2px); }
17233
- .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; }
17234
17663
  .toolbar .spacer { flex:1; }
17235
- .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; }
17236
17665
  .tbtn:hover { background:var(--hover-2); color:var(--fg); }
17237
17666
  .tbtn.on { color:var(--accent); }
17238
- .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); }
17239
17712
  .tablescroll { overflow-x:auto; }
17240
17713
  /* fixed layout + max-content: each <col> width is authoritative, so the table
17241
17714
  is exactly the sum of its columns and resizing one column never redistributes
@@ -17244,12 +17717,26 @@ var styles_default = ` :root {
17244
17717
  table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
17245
17718
  table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
17246
17719
  table.grid th:last-child, table.grid td:last-child { border-right:0; }
17247
- table.grid tbody tr:last-child td { border-bottom:0; }
17248
- thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
17249
- .colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
17250
- .colhead:hover { background:var(--hover); }
17251
- .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; }
17252
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; }
17253
17740
  .col-resizer { position:absolute; top:0; right:-4px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
17254
17741
  .addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
17255
17742
  /* trailing filler column: empty canvas to the right of the data, not a real cell */
@@ -17257,19 +17744,24 @@ var styles_default = ` :root {
17257
17744
  body.col-resizing { user-select:none; cursor:col-resize; }
17258
17745
  table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
17259
17746
  /* relative: anchors the .celledit overlay editor to the cell */
17260
- 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; }
17261
17749
  .cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
17262
17750
  /* hover/active tint goes on the <td> (always full row height) not the inner
17263
17751
  .cell (only as tall as its content), so a taller sibling row never leaves a
17264
17752
  gap of un-tinted space at the bottom of the hovered cell. */
17265
17753
  td.cell-td { transition:background .1s; }
17266
- td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
17754
+ .cell.center { justify-content:center; }
17267
17755
  tbody tr { transition:background .08s; }
17268
17756
  tbody tr:hover { background:var(--hover); }
17269
17757
  tbody tr.sel { background:var(--accent-soft); }
17270
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. */
17271
17759
  td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
17272
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; }
17273
17765
  body.cell-selecting .cell { cursor:cell; }
17274
17766
  /* column reorder still marks the target <th>; rows use the .rowdrop line below */
17275
17767
  table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
@@ -17325,23 +17817,57 @@ var styles_default = ` :root {
17325
17817
  /* data-resolved (not prefers-color-scheme): tracks a manual dark choice too. */
17326
17818
  :root[data-resolved="dark"] .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); }
17327
17819
  /* relation chip: an <a> that jumps to the target record */
17328
- 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; }
17329
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; }
17330
17822
  /* doc chip whose target is deleted/unsynced: muted + dashed, same cue as the
17331
17823
  editor's .cm-doclink-missing pill */
17332
17824
  a.chip.rel.doc-missing { background:transparent; color:var(--muted); outline:1px dashed var(--line-strong); outline-offset:-1px; }
17333
- .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; }
17334
17826
  .addrow:hover { background:var(--hover); color:var(--fg-soft); }
17827
+ .addrow svg { width:14px; height:14px; }
17335
17828
  .gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
17336
- 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; }
17337
17832
  /* Overlay cell editor: floats over the td; the display content underneath
17338
17833
  stays in flow so the row height never changes while editing. Padding on the
17339
- 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. */
17340
17836
  .celledit { position:absolute; top:0; left:0; width:100%; min-height:100%; z-index:10;
17341
- display:flex; align-items:stretch; background:var(--bg); border-radius:var(--radius-sm);
17342
- box-shadow:0 0 0 2px var(--accent), var(--shadow-md); }
17343
- .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;
17344
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; }
17345
17871
 
17346
17872
  /* ---- sites (\u7AD9\u70B9\u7BA1\u7406) ---- */
17347
17873
  .sites-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(264px, 1fr)); gap:14px; margin-top:4px; }
@@ -17690,6 +18216,11 @@ var styles_default = ` :root {
17690
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; }
17691
18217
  .proprow .v:hover { background:var(--hover); }
17692
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; }
17693
18224
  /* linked-document tabs + embedded editor below the peek's property list */
17694
18225
  .peekdocs-tabs { display:flex; align-items:center; gap:4px; flex-wrap:wrap; margin-bottom:6px; }
17695
18226
  .peekdocs-tab { border:0; background:none; font:inherit; font-size:var(--fs-sm); font-weight:500; color:var(--muted);
@@ -17720,7 +18251,7 @@ var styles_default = ` :root {
17720
18251
  .hist-kind.repair { background:var(--danger-soft); color:var(--danger); }
17721
18252
  .hist-now { font-size:var(--fs-xs); padding:1px 6px; border-radius:99px; background:var(--accent); color:var(--accent-fg); }
17722
18253
  .hist-preview { flex:1; overflow:auto; padding:20px 24px; }
17723
- .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; }
17724
18255
  .hist-foot { display:flex; justify-content:flex-end; padding:12px 16px; border-top:1px solid var(--line); }
17725
18256
  .pad { padding:12px; }
17726
18257
  .hist-rec-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
@@ -17827,50 +18358,68 @@ var styles_default = ` :root {
17827
18358
  button.fname.link { border:0; background:none; padding:0; text-align:left; cursor:pointer; font-size:inherit; }
17828
18359
  button.fname.link:hover { color:var(--accent); text-decoration:underline; text-decoration-style:dashed; }
17829
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
+
17830
18380
  /* menu / popover */
17831
18381
  .menu-layer { position:fixed; inset:0; z-index:115; }
17832
18382
  .pop { position:fixed; background:var(--bg); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-lg);
17833
- 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; }
17834
18384
  @keyframes pop { from { opacity:0; transform:translateY(-4px); } }
17835
18385
  /* Action-sheet variant: MenuHost (ui.tsx) adds .sheet on phones, where an
17836
18386
  anchored popover clips at the edges and sits under the finger. */
17837
18387
  .pop.sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom));
17838
- 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);
17839
18389
  animation:sheet-in .18s ease; }
17840
18390
  @keyframes sheet-in { from { opacity:0; transform:translateY(12px); } }
17841
- .pop .lbl { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
17842
- .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); }
17843
- .pop .item:hover, .pop .item.sel { background:var(--hover-2); }
17844
- .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; }
17845
- .pop .item .lico.plain { border:0; width:22px; height:22px; }
17846
- .pop .item .meta { flex:1; min-width:0; }
17847
- .pop .item .t { font-size:var(--fs-ui); } .pop .item .d { font-size:var(--fs-xs); color:var(--muted); display:block; }
17848
- .pop .item .chk { color:var(--accent); margin-left:auto; }
17849
- /* shortcut badge, mirrors .sb-search kbd; margin-left:auto pins it right (and
17850
- a trailing .chk, whose own auto then collapses, rides the gap beside it) */
17851
- .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; }
17852
- .pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
17853
- .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; }
17854
18403
  .pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
17855
18404
  .pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
17856
18405
  .pop .typegrid .item .lico { margin:0 auto; }
17857
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; }
17858
18407
  .pop input.field:focus { border-color:var(--accent); }
17859
18408
  /* select-cell picker: search/create input at the top of the popover */
17860
- .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;
17861
18410
  border:1px solid var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); color:var(--muted);
17862
18411
  transition:border-color .12s ease, box-shadow .12s ease, color .12s ease; }
17863
- .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);
17864
18413
  box-shadow:0 0 0 2.5px color-mix(in srgb, var(--accent) 16%, transparent); }
17865
- .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;
17866
18415
  font:inherit; font-size:var(--fs-ui); color:var(--fg); }
17867
- .pop .selsearch input::placeholder { color:var(--muted); }
17868
- .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;
17869
18418
  border-radius:50%; color:var(--muted); flex:none; }
17870
- .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); }
17871
18420
  /* relation record picker: fixed-height list \u2014 the popover is positioned once
17872
18421
  on open, so async-loaded rows must scroll instead of growing off-screen */
17873
- .pop .rellist { max-height:264px; overflow-y:auto; }
18422
+ :is(.pop,.palette) .rellist { max-height:264px; overflow-y:auto; }
17874
18423
  .optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
17875
18424
  .optrow .chip { flex:1; }
17876
18425
  .optrow .grip { width:18px; height:22px; display:grid; place-items:center; color:var(--muted); cursor:grab; touch-action:none; flex:none; }
@@ -17915,12 +18464,46 @@ var styles_default = ` :root {
17915
18464
  /* opt-in (openModal aboveMenus): stack one dialog over the menu layer (115)
17916
18465
  so a confirm can appear without dismissing an open popover */
17917
18466
  .modal-scrim.above-menus { z-index:130; }
17918
- .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; }
17919
18501
  .modal-head { padding:20px 22px 0; }
17920
18502
  .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
17921
18503
  .modal-head p { margin:6px 0 0; color:var(--muted); font-size:var(--fs-ui); }
17922
18504
  .modal-body { padding:18px 22px; overflow-y:auto; }
17923
- .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); }
17924
18507
  .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
17925
18508
  .field-label:first-child { margin-top:0; }
17926
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); }
@@ -17943,6 +18526,12 @@ var styles_default = ` :root {
17943
18526
  .toast { background:var(--fg); color:var(--bg); padding:10px 14px; border-radius:9px; font-size:13px; box-shadow:var(--shadow-lg);
17944
18527
  display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
17945
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); }
17946
18535
 
17947
18536
  /* misc */
17948
18537
  .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
@@ -18010,9 +18599,74 @@ var styles_default = ` :root {
18010
18599
  .kg-glow { opacity:.6 !important; }
18011
18600
  .kg-particle { opacity:0 !important; }
18012
18601
  }
18013
- .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; }
18014
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; }
18015
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; }
18016
18670
  .search-hit:hover { background:var(--surface-2); }
18017
18671
  .search-hit mark { background:transparent; color:var(--accent); font-weight:600; }
18018
18672
  .view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
@@ -18235,17 +18889,15 @@ var styles_default = ` :root {
18235
18889
  .navitem { padding:9px 8px; font-size:16px; }
18236
18890
  .navitem .emoji { font-size:18px; }
18237
18891
  .navitem .tw { width:26px; height:26px; }
18238
- .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 */
18239
18893
  /* Search box is hidden until the header search button reveals it; it then
18240
18894
  slides+fades down in. Desktop base (.sb-search{display:flex}) is untouched,
18241
18895
  so search stays always-on there. reduced-motion is handled globally. */
18242
18896
  .sb-search { display:none; }
18243
- .sb-search.open { display:flex; animation:search-reveal .22s cubic-bezier(.32,.72,0,1); }
18244
- @keyframes search-reveal { from { opacity:0; transform:translateY(-8px); } }
18245
18897
  .btn { font-size:15px; }
18246
18898
  .crumb { font-size:16px; }
18247
- .pop .item { font-size:16px; }
18248
- .pop .item .t { font-size:16px; }
18899
+ :is(.pop,.palette) .item { font-size:16px; }
18900
+ :is(.pop,.palette) .item .t { font-size:16px; }
18249
18901
 
18250
18902
  /* Document: bigger body text, tighter side padding for narrow screens. */
18251
18903
  .doc { padding:24px 18px calc(36vh + env(safe-area-inset-bottom)); }
@@ -18296,11 +18948,24 @@ var styles_default = ` :root {
18296
18948
  border-radius:var(--radius-xl) var(--radius-xl) 0 0;
18297
18949
  padding-bottom:env(safe-area-inset-bottom);
18298
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; }
18299
18961
  .toasts { left:12px; right:12px; bottom:calc(12px + env(safe-area-inset-bottom)); }
18300
18962
  .toast { justify-content:center; }
18301
18963
  .selbar { bottom:calc(16px + env(safe-area-inset-bottom)); max-width:calc(100vw - 24px); }
18302
18964
  }
18303
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%); } }
18304
18969
  /* In-flow stand-in for the old two-pane slide: only the entering view
18305
18970
  animates (the other is display:none, document-scroll layout above). */
18306
18971
  @keyframes page-in { from { transform:translateX(28px); opacity:0; } }
@@ -18317,7 +18982,7 @@ var styles_default = ` :root {
18317
18982
  .sb-tabs .add { width:40px; height:40px; display:grid; place-items:center; padding:0; }
18318
18983
  .iconbtn { width:40px; height:40px; }
18319
18984
  .btn { padding:9px 14px; min-height:40px; }
18320
- .pop .item { padding:11px 10px; }
18985
+ :is(.pop,.palette) .item { padding:11px 10px; }
18321
18986
  /* Column/sidebar resize handles: 7px is a mouse affordance, not a finger's. */
18322
18987
  .col-resizer, .doc-col-resizer, .sb-resizer { width:16px; right:-8px; }
18323
18988
  /* cell-rectangle corner handle: big enough to grab with a finger (it is the
@@ -18391,6 +19056,8 @@ var styles_default = ` :root {
18391
19056
  .qn-body { flex:1; overflow:auto; scrollbar-gutter:stable; scrollbar-width:thin; -webkit-app-region:no-drag; }
18392
19057
  .qn-body .doc { max-width:none; margin:0; padding:14px 1px 30vh 12px; }
18393
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; }
18394
19061
  .qn-body .doc-meta { display:none; }
18395
19062
  /* The CM6 hover gutter never fits the compact window: quick notes offer no
18396
19063
  block insert/drag handles (Enter / slash commands cover insertion). */
@@ -18411,6 +19078,7 @@ var styles_default = ` :root {
18411
19078
  share .pop/.item/.lico with the main app; scope under the quick window
18412
19079
  body classes so the main app keeps its roomier density). */
18413
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; }
18414
19082
  body.quicknote .pop .lbl, body.quickboard .pop .lbl { padding:4px 8px 2px; font-size:10px; }
18415
19083
  body.quicknote .pop .item, body.quickboard .pop .item { padding:5px 9px; gap:8px; font-size:var(--fs-sm); }
18416
19084
  body.quicknote .pop .item .t, body.quickboard .pop .item .t { font-size:var(--fs-sm); }
@@ -18645,17 +19313,7 @@ var styles_default = ` :root {
18645
19313
  .switch.on .switch-knob { transform:translateX(16px); }
18646
19314
  .ico.spin { animation:save-spin .8s linear infinite; }
18647
19315
 
18648
- /* clear-policy segmented control (redundancy any/all) */
18649
19316
  .seg { display:flex; gap:8px; }
18650
- .seg-opt { flex:1 1 0; display:flex; flex-direction:column; gap:2px; align-items:flex-start;
18651
- text-align:left; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--radius);
18652
- background:var(--surface); cursor:pointer; transition:border-color .14s, background .14s, box-shadow .14s; }
18653
- .seg-opt:hover { border-color:var(--muted); }
18654
- .seg-opt.on { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset; }
18655
- .seg-opt:disabled { opacity:.6; cursor:default; }
18656
- .seg-opt-t { font-size:var(--fs-sm); font-weight:600; color:var(--fg); }
18657
- .seg-opt-s { font-size:var(--fs-xs); color:var(--muted); }
18658
- .seg-opt.on .seg-opt-s { color:var(--accent); }
18659
19317
 
18660
19318
  /* cache-usage ring hero (settings/cache-ring.tsx CacheRingHero): legend
18661
19319
  column left, 152px three-segment ring right. Arcs animate 0 \u2192 share on
@@ -18671,9 +19329,6 @@ var styles_default = ` :root {
18671
19329
  .blob-dot.keep { background:var(--line-strong); }
18672
19330
  .blob-dot.pin { background:var(--muted); opacity:.6; }
18673
19331
  .blob-total { margin:13px 0 14px; font-size:var(--fs-xs); color:var(--muted); }
18674
- /* headline number over the legend (\u6570\u636E\u4E0E\u5907\u4EFD \xB7 \u9644\u4EF6) */
18675
- .blob-stat { font-size:20px; font-weight:700; letter-spacing:-.02em; font-variant-numeric:tabular-nums; margin-bottom:10px; }
18676
- .blob-stat small { font-size:var(--fs-sm); font-weight:500; color:var(--muted); margin-left:8px; letter-spacing:0; }
18677
19332
  /* a status sentence in the action row (never a disabled button) */
18678
19333
  .blob-actions .blob-status { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--muted); }
18679
19334
  .blob-actions .blob-status .ico { width:14px; height:14px; color:var(--success); }
@@ -18821,7 +19476,7 @@ var FMT_PROVIDERS = [
18821
19476
  init_package();
18822
19477
  var RUNNING_FROM_SOURCE = import.meta.url.includes("/src/webui/");
18823
19478
  var HTML2 = `<!doctype html>
18824
- <html lang="zh">
19479
+ <html lang="zh-CN">
18825
19480
  <head>
18826
19481
  <meta charset="utf-8" />
18827
19482
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
@@ -18845,6 +19500,9 @@ var HTML2 = `<!doctype html>
18845
19500
  alone, so the dark palette exists exactly once \u2014 see styles.css. Dark also
18846
19501
  pre-tints the status bar (hex mirrors --bg) so the first frame isn't white. -->
18847
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>
18848
19506
  <link rel="manifest" href="/manifest.webmanifest">
18849
19507
  <link rel="apple-touch-icon" href="/icons/icon-180.png">
18850
19508
  <link rel="stylesheet" href="/webui.css">
@@ -19095,6 +19753,358 @@ async function serveWebui(req) {
19095
19753
  }
19096
19754
  // src/webui/server/routes.ts
19097
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
19098
20108
  init_errors();
19099
20109
  init_routes();
19100
20110
  init_databases();
@@ -19107,6 +20117,7 @@ init_crdt();
19107
20117
  init_documents();
19108
20118
  init_properties();
19109
20119
  init_errors();
20120
+ init_history_cache();
19110
20121
  var REVISION_GAP_MS = 1500;
19111
20122
  function hlcMillis(hlc) {
19112
20123
  return Number(hlc.slice(0, 15));
@@ -19164,16 +20175,23 @@ function rowChanges(db, dataset, rowId) {
19164
20175
  function everBlockIds(db, docId) {
19165
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);
19166
20177
  }
19167
- function docChanges(db, docId) {
20178
+ function docHistory(db, docId) {
20179
+ const cached = getDocHistory(db, docId);
20180
+ if (cached)
20181
+ return cached;
19168
20182
  const blocks = everBlockIds(db, docId);
19169
20183
  const placeholders = blocks.map(() => "?").join(",");
19170
20184
  const blockClause = blocks.length ? ` OR (dataset = 'doc_blocks' AND row_id IN (${placeholders}))` : "";
19171
- return db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes
20185
+ const changes = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes
19172
20186
  WHERE (dataset = 'documents' AND row_id = ?)${blockClause}
19173
20187
  ORDER BY hlc`).all(docId, ...blocks);
20188
+ return setDocHistory(db, docId, changes, blocks);
19174
20189
  }
19175
20190
  function listDocumentRevisions(db, id) {
19176
- 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;
19177
20195
  if (!changes.length)
19178
20196
  throw new MhError("not_found", `no such document: ${id}`);
19179
20197
  const reg = new Map;
@@ -19228,12 +20246,17 @@ function listDocumentRevisions(db, id) {
19228
20246
  blocks_deleted: deletedBlocks.size
19229
20247
  });
19230
20248
  }
19231
- return out.reverse();
20249
+ entry.revisions = out.reverse();
20250
+ return entry.revisions.slice();
19232
20251
  }
19233
20252
  function documentAtVersion(db, id, at) {
19234
20253
  if (!at)
19235
20254
  throw new MhError("invalid_input", "missing version cutoff");
19236
- 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);
19237
20260
  if (!changes.length)
19238
20261
  throw new MhError("not_found", `no version of document ${id} at or before ${at}`);
19239
20262
  const docRegs = registersAt(changes.filter((c) => c.dataset === "documents"), at);
@@ -19279,13 +20302,15 @@ function documentAtVersion(db, id, at) {
19279
20302
  return a.id < b.id ? -1 : 1;
19280
20303
  });
19281
20304
  const body = byBlock.size ? serializeDocBlocks(blocks) : parse("body") ?? "";
19282
- return {
20305
+ const state = {
19283
20306
  id,
19284
20307
  title: parse("title") ?? "",
19285
20308
  body,
19286
20309
  deleted: docRegs.get("__deleted") ? flagSet(docRegs.get("__deleted").value) : false,
19287
20310
  version: changes[changes.length - 1].hlc
19288
20311
  };
20312
+ rememberDocState(entry, at, state);
20313
+ return { ...state };
19289
20314
  }
19290
20315
  var revertDocument = grouped(function revertDocument(db, id, to, opts = {}) {
19291
20316
  let cur = getDocument(db, id);
@@ -19917,6 +20942,7 @@ var revertChangeGroup = grouped(function revertChangeGroup(db, txn) {
19917
20942
  }, "revert");
19918
20943
 
19919
20944
  // src/webui/server/routes.ts
20945
+ init_resolve();
19920
20946
  init_node();
19921
20947
  init_blobs();
19922
20948
  init_config();
@@ -20081,7 +21107,7 @@ async function createBucketShare(db, opts) {
20081
21107
  contentType: "application/octet-stream"
20082
21108
  });
20083
21109
  await mergeViewerCors(opts.config, opts.viewerOrigin);
20084
- return { manifestUrl, presignExp, keyB64: opts.password ? undefined : toB64(shareKey), saltB64, title };
21110
+ return { manifestUrl, presignExp, keyB64: opts.password ? undefined : toB64(shareKey), saltB64, title, meta };
20085
21111
  }
20086
21112
  async function renewBucketShare(db, config, slug, expiresSec = MAX_PRESIGN_SECONDS) {
20087
21113
  const meta = await readMeta2(config, slug);
@@ -20148,7 +21174,7 @@ async function represignBucketShare(config, slug, expiresSec = MAX_PRESIGN_SECON
20148
21174
  async function listBucketShares(config) {
20149
21175
  const client = storageClientFor(config);
20150
21176
  const root = sharesRoot(config.prefix);
20151
- const entries = await client.list(`${root}/`, undefined, "/").catch(() => []);
21177
+ const entries = await client.list(`${root}/`, undefined, "/");
20152
21178
  const slugs = entries.map((e) => e.key.slice(`${root}/`.length).replace(/\/$/, "")).filter((s) => s && !s.includes("/"));
20153
21179
  const out = [];
20154
21180
  for (const slug of slugs) {
@@ -20177,10 +21203,156 @@ function clampExpiry(sec) {
20177
21203
 
20178
21204
  // src/core/sync/share-actions.ts
20179
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
20180
21353
  init_site_channel_store();
20181
21354
  var DEFAULT_SHARE_VIEWER = "https://share.mh.tensorix.org";
20182
21355
  var DEFAULT_S3_EXPIRY_SEC = MAX_PRESIGN_SECONDS;
20183
- var PEER_LIST_TIMEOUT_MS = 4000;
20184
21356
  var PEER_SHARE_TIMEOUT_MS = 1e4;
20185
21357
  var CreatedShareWireSchema = object({
20186
21358
  slug: string2().min(1).max(128),
@@ -20277,13 +21449,6 @@ function bucketLink(base, manifestUrl, keyB64, saltB64) {
20277
21449
  const frag = `m=${encodeURIComponent(manifestUrl)}&` + (keyB64 ? `k=${keyB64}` : `s=${saltB64}`);
20278
21450
  return `${base}/#${frag}`;
20279
21451
  }
20280
- function absoluteShareUrl(url, base) {
20281
- try {
20282
- return new URL(url, `${base.replace(/\/+$/, "")}/`).toString();
20283
- } catch {
20284
- return url;
20285
- }
20286
- }
20287
21452
  function assertShareCombo(req) {
20288
21453
  const fail = (msg) => {
20289
21454
  throw new MhError("invalid_input", msg);
@@ -20321,6 +21486,7 @@ async function createShareAction(db, req) {
20321
21486
  expiresSec: req.expiresMs != null ? Math.floor(req.expiresMs / 1000) : DEFAULT_S3_EXPIRY_SEC,
20322
21487
  viewerOrigin: viewerOriginOf(base)
20323
21488
  });
21489
+ primeRemoteShare(db, bucketUrl, bucketShareItem({ url: bucketUrl, label }, out.meta));
20324
21490
  return {
20325
21491
  slug,
20326
21492
  kind: req.kind,
@@ -20510,28 +21676,21 @@ function listServerSharesLocal(db, targetId) {
20510
21676
  }
20511
21677
  return out;
20512
21678
  }
20513
- async function listSharesLocal(db, targetId) {
21679
+ async function listSharesLocal(db, targetId, opts = {}) {
20514
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
+ }
20515
21687
  for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20516
21688
  const config = JSON.parse(p.config);
20517
21689
  const metas = await listBucketShares(config).catch(() => []);
20518
21690
  for (const m of metas) {
20519
21691
  if (targetId && m.target_id !== targetId)
20520
21692
  continue;
20521
- out.push({
20522
- slug: m.slug,
20523
- kind: m.kind,
20524
- target_id: m.target_id,
20525
- title: m.title || m.target_id,
20526
- permission: m.permission,
20527
- transport: "s3",
20528
- source: `\u6876 ${p.label ?? p.url}`,
20529
- sourceKind: "bucket",
20530
- hosting: "s3",
20531
- expiresAt: m.presign_exp,
20532
- hasPassword: m.has_password,
20533
- contentUpdatedAt: m.content_updated_at ?? m.created_at
20534
- });
21693
+ out.push(bucketShareItem(p, m));
20535
21694
  }
20536
21695
  }
20537
21696
  return out;
@@ -20561,10 +21720,16 @@ async function revokeShareByRequestId(db, requestId) {
20561
21720
  const share = getShareByRequestId(db, requestId);
20562
21721
  return share ? revokeShareAction(db, share.slug) : { ok: false, status: "not_found" };
20563
21722
  }
20564
- async function listSharesAggregated(db, targetId) {
21723
+ async function listSharesAggregated(db, targetId, opts = {}) {
20565
21724
  const bySlug = new Map;
20566
- for (const item of await listSharesLocal(db, targetId))
21725
+ for (const item of await listSharesLocal(db, targetId, opts))
20567
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
+ }
20568
21733
  await Promise.all(listShareServers(db).map(async ({ url, label }) => {
20569
21734
  const peer = getPeer2(db, url);
20570
21735
  if (!peer?.token)
@@ -20582,19 +21747,6 @@ async function listSharesAggregated(db, targetId) {
20582
21747
  }));
20583
21748
  return [...bySlug.values()];
20584
21749
  }
20585
- async function fetchPeerShares(url, token, targetId) {
20586
- const u = `${url.replace(/\/+$/, "")}/api/shares${targetId ? `?target=${encodeURIComponent(targetId)}` : ""}`;
20587
- const res = await Promise.race([
20588
- fetch(u, { headers: { authorization: `Bearer ${token}` } }),
20589
- new Promise((_r, rej) => setTimeout(() => rej(new Error("timeout")), PEER_LIST_TIMEOUT_MS))
20590
- ]);
20591
- if (!res.ok)
20592
- return [];
20593
- return (await res.json()).map((item) => ({
20594
- ...item,
20595
- ...item.url ? { url: absoluteShareUrl(item.url, url) } : {}
20596
- }));
20597
- }
20598
21750
  async function revokeShareAction(db, slug) {
20599
21751
  if (getShare(db, slug)) {
20600
21752
  const channels = listSiteChannelRows(db).filter((channel) => channel.audience === "link" && channel.target_ref === slug && channel.desired_state === "active");
@@ -20623,37 +21775,50 @@ async function revokeShareAction(db, slug) {
20623
21775
  status: "revoked"
20624
21776
  };
20625
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 };
20626
21789
  for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20627
- const config = JSON.parse(p.config);
20628
- const metas = await listBucketShares(config).catch(() => []);
20629
- if (metas.some((m) => m.slug === slug)) {
20630
- await deleteBucketShareObjects(config, slug);
20631
- return { ok: true, status: "revoked" };
20632
- }
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 };
20633
21794
  }
20634
- return { ok: false, status: "not_found" };
21795
+ return null;
20635
21796
  }
20636
21797
  async function renewShareAction(db, slug, viewerBaseOverride, opts) {
20637
- for (const p of listPeers(db).filter((x) => x.kind === "s3" && x.config)) {
20638
- const config = JSON.parse(p.config);
20639
- const metas = await listBucketShares(config).catch(() => []);
20640
- const m = metas.find((x) => x.slug === slug);
20641
- if (!m)
20642
- continue;
20643
- const base = viewerBase(db, viewerBaseOverride);
20644
- const out = opts?.refreshContent ? await renewBucketShare(db, config, slug) : await represignBucketShare(config, slug);
20645
- return {
20646
- slug,
20647
- kind: m.kind,
20648
- permission: "view",
20649
- transport: "s3",
20650
- hosting: "s3",
20651
- 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,
20652
21809
  expiresAt: out.presignExp,
20653
- source: `\u6876 ${p.label ?? p.url}`
20654
- };
20655
- }
20656
- 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
+ };
20657
21822
  }
20658
21823
  function freshSlug(db) {
20659
21824
  let slug = randomSuffix(12);
@@ -20752,7 +21917,7 @@ var shareRoutes = [
20752
21917
  handler: async (req, { db }) => {
20753
21918
  try {
20754
21919
  const target = new URL(req.url).searchParams.get("target") ?? undefined;
20755
- return Response.json(await listSharesLocal(db, target));
21920
+ return Response.json(await listSharesLocal(db, target, { mode: "cached" }));
20756
21921
  } catch (e) {
20757
21922
  return errorResponse(e);
20758
21923
  }
@@ -20766,7 +21931,7 @@ var shareRoutes = [
20766
21931
  handler: async (req, { db }) => {
20767
21932
  try {
20768
21933
  const target = new URL(req.url).searchParams.get("target") ?? undefined;
20769
- return Response.json(await listSharesAggregated(db, target));
21934
+ return Response.json(await listSharesAggregated(db, target, { mode: "cached" }));
20770
21935
  } catch (e) {
20771
21936
  return errorResponse(e);
20772
21937
  }
@@ -21057,7 +22222,7 @@ function jsonHandler(fn) {
21057
22222
  try {
21058
22223
  return Response.json(await fn(req, ctx) ?? null);
21059
22224
  } catch (e) {
21060
- return errorResponse(e);
22225
+ return errorResponse(e, req);
21061
22226
  }
21062
22227
  };
21063
22228
  }
@@ -21227,20 +22392,24 @@ async function verifyBase(input, expectedNode, allowRemote = true) {
21227
22392
  headers: { accept: "application/json" }
21228
22393
  });
21229
22394
  if (res.status < 200 || res.status >= 300)
21230
- 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 });
21231
22396
  const body = await limitedJson(res, HEALTH_RESPONSE_MAX);
21232
22397
  if (!body?.ok || !body.node)
21233
22398
  throw new MhError("network", "\u7AD9\u70B9\u5165\u53E3\u5065\u5EB7\u54CD\u5E94\u65E0\u6548");
21234
22399
  if (body.node !== expectedNode)
21235
- 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
+ });
21236
22404
  if (!Array.isArray(body.capabilities) || !body.capabilities.includes("site_channels"))
21237
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");
21238
22406
  return { ...base, node: body.node };
21239
22407
  } catch (e) {
21240
22408
  if (e instanceof MhError)
21241
22409
  throw e;
21242
- const message = e.name === "AbortError" ? "\u8FDE\u63A5\u8D85\u65F6" : e.message;
21243
- 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 });
21244
22413
  } finally {
21245
22414
  clearTimeout(timer);
21246
22415
  }
@@ -21561,6 +22730,7 @@ var siteHostingRoutes = [
21561
22730
  // src/webui/server/edge-routes.ts
21562
22731
  init_zod();
21563
22732
  init_errors();
22733
+ init_page_messages();
21564
22734
 
21565
22735
  // src/core/sync/edge-service.ts
21566
22736
  init_errors();
@@ -21727,6 +22897,7 @@ init_errors();
21727
22897
  init_http_status();
21728
22898
  init_ids();
21729
22899
  init_access_policy();
22900
+ init_page_messages();
21730
22901
  init_grants_routes();
21731
22902
  init_rate_limit();
21732
22903
  init_sites_core();
@@ -22208,6 +23379,7 @@ function disconnectEdge(db) {
22208
23379
 
22209
23380
  // src/core/sync/cf-oauth.ts
22210
23381
  init_errors();
23382
+ init_page_messages();
22211
23383
  init_e2ee();
22212
23384
  var CF_OAUTH_CLIENT_ID = process.env.METAHUB_CF_OAUTH_CLIENT_ID || "0062224f5b80ba3a2074780e73a9d305";
22213
23385
  var AUTH_ENDPOINT = "https://dash.cloudflare.com/oauth2/auth";
@@ -22257,7 +23429,7 @@ function buildTokenRequestBody(p) {
22257
23429
  function parseTokenResponse(data) {
22258
23430
  const d = data ?? {};
22259
23431
  if (!d.access_token)
22260
- 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" });
22261
23433
  return {
22262
23434
  accessToken: d.access_token,
22263
23435
  refreshToken: d.refresh_token ?? null,
@@ -22277,11 +23449,11 @@ async function exchangeCode(p) {
22277
23449
  body: buildTokenRequestBody(p)
22278
23450
  });
22279
23451
  } catch (e) {
22280
- 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 });
22281
23453
  }
22282
23454
  const data = await res.json().catch(() => null);
22283
23455
  if (!res.ok && !data?.access_token)
22284
- 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 });
22285
23457
  return parseTokenResponse(data);
22286
23458
  }
22287
23459
  async function discoverAccounts(accessToken) {
@@ -22291,11 +23463,11 @@ async function discoverAccounts(accessToken) {
22291
23463
  headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }
22292
23464
  });
22293
23465
  } catch (e) {
22294
- 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 });
22295
23467
  }
22296
23468
  const data = await res.json().catch(() => null);
22297
23469
  if (!res.ok)
22298
- 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 });
22299
23471
  return parseAccounts(data);
22300
23472
  }
22301
23473
  async function startCfLogin(opts = {}) {
@@ -22314,7 +23486,9 @@ async function startCfLogin(opts = {}) {
22314
23486
  if (url.pathname !== CALLBACK_PATH)
22315
23487
  return new Response("not found", { status: 404 });
22316
23488
  settle(url.searchParams);
22317
- 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
+ });
22318
23492
  }
22319
23493
  });
22320
23494
  const redirectUri = `http://127.0.0.1:${server.port}${CALLBACK_PATH}`;
@@ -22345,7 +23519,7 @@ async function startCfLogin(opts = {}) {
22345
23519
  try {
22346
23520
  const q = await Promise.race([caught, timeout]);
22347
23521
  if (q.get("error"))
22348
- 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") || "" });
22349
23523
  if (q.get("state") !== state)
22350
23524
  throw new MhError("auth", "OAuth state \u4E0D\u5339\u914D\uFF0C\u7591\u4F3C\u4F2A\u9020\u56DE\u8C03\uFF0C\u5DF2\u4E2D\u6B62");
22351
23525
  const code = q.get("code");
@@ -22366,9 +23540,9 @@ async function startCfLogin(opts = {}) {
22366
23540
  }
22367
23541
  };
22368
23542
  }
22369
- 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>
22370
23544
  <body style="font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0">
22371
- <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>`;
22372
23546
 
22373
23547
  // src/webui/server/edge-routes.ts
22374
23548
  init_ids();
@@ -22439,7 +23613,7 @@ function resolveCfCreds(body) {
22439
23613
  if (!flow)
22440
23614
  throw new MhError("not_found", "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55");
22441
23615
  if (flow.state === "error")
22442
- 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);
22443
23617
  if (flow.state !== "ready" || !flow.token)
22444
23618
  throw new MhError("conflict", "Cloudflare \u6388\u6743\u5C1A\u672A\u5B8C\u6210\uFF0C\u8BF7\u7A0D\u5019");
22445
23619
  apiToken = flow.token;
@@ -22483,6 +23657,7 @@ var edgeRoutes = [
22483
23657
  }).catch((e) => {
22484
23658
  flow.state = "error";
22485
23659
  flow.error = e.message;
23660
+ flow.errorI18n = e instanceof MhError ? e.i18n : undefined;
22486
23661
  });
22487
23662
  return { flowId, authUrl: handle.authUrl };
22488
23663
  })
@@ -22501,7 +23676,8 @@ var edgeRoutes = [
22501
23676
  const flow = flows.get(flowId);
22502
23677
  if (!flow)
22503
23678
  throw new MhError("not_found", "OAuth \u6D41\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5");
22504
- 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 };
22505
23681
  })
22506
23682
  },
22507
23683
  {
@@ -22603,6 +23779,7 @@ var DatabaseSchema = object({
22603
23779
  id: string2(),
22604
23780
  name: string2(),
22605
23781
  icon: string2().nullable(),
23782
+ meta: record(string2(), any()).nullable(),
22606
23783
  created_hlc: string2()
22607
23784
  });
22608
23785
  var PropertySchema = object({
@@ -22646,11 +23823,18 @@ var SearchHitSchema = object({
22646
23823
  title: string2().optional(),
22647
23824
  snippet: string2()
22648
23825
  });
23826
+ var LookupHitSchema = object({
23827
+ kind: _enum(["db", "doc", "rec"]),
23828
+ id: string2(),
23829
+ label: string2(),
23830
+ database_id: string2().nullable()
23831
+ });
22649
23832
  var OkSchema2 = object({ ok: boolean2() });
22650
23833
  var CreateDatabaseReq = object({ name: string2(), icon: string2().optional() });
22651
23834
  var UpdateDatabaseReq = object({
22652
23835
  name: string2().optional(),
22653
- 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")
22654
23838
  });
22655
23839
  var DuplicateDatabaseReq = object({
22656
23840
  name: string2().optional().describe("Name for the copy (defaults to the source name)"),
@@ -22909,7 +24093,7 @@ function handle3(fn) {
22909
24093
  return out;
22910
24094
  return Response.json(out ?? null);
22911
24095
  } catch (e) {
22912
- return errorResponse(e);
24096
+ return errorResponse(e, req);
22913
24097
  }
22914
24098
  };
22915
24099
  }
@@ -22972,7 +24156,7 @@ var webuiRoutes = [
22972
24156
  {
22973
24157
  method: "PATCH",
22974
24158
  path: "/api/database",
22975
- 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>",
22976
24160
  request: UpdateDatabaseReq,
22977
24161
  response: DatabaseSchema,
22978
24162
  handler: handle3(async (req, { db }) => {
@@ -23358,6 +24542,16 @@ var webuiRoutes = [
23358
24542
  return search(db, need3(req, "q"), { limit: limit ? Number(limit) : undefined });
23359
24543
  })
23360
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
+ },
23361
24555
  {
23362
24556
  method: "GET",
23363
24557
  path: "/api/blob-cache",
@@ -23382,6 +24576,13 @@ var webuiRoutes = [
23382
24576
  response: ClearResultSchema,
23383
24577
  handler: handle3((_req, { db }) => clearCache(db))
23384
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
+ },
23385
24586
  {
23386
24587
  method: "POST",
23387
24588
  path: "/api/blob-cache/pin",
@@ -23455,6 +24656,7 @@ export {
23455
24656
  DOCUMENT_COLS,
23456
24657
  DOMAIN,
23457
24658
  HEALTH_PATH,
24659
+ HLC_MAX_SKEW_MS,
23458
24660
  HealthResponseSchema,
23459
24661
  INLINE_LIMIT,
23460
24662
  MAX_HLC_COUNTER,
@@ -23480,6 +24682,7 @@ export {
23480
24682
  advanceAckedPrefix,
23481
24683
  appendDocument,
23482
24684
  applyChange,
24685
+ asMhError,
23483
24686
  backfillDocumentOrderKeys,
23484
24687
  backfillRecordOrderKeys,
23485
24688
  base64ToBytes,
@@ -23540,12 +24743,16 @@ export {
23540
24743
  getSiteByName,
23541
24744
  grouped,
23542
24745
  hasIndex,
24746
+ hlcSkewBound,
23543
24747
  idKind,
23544
24748
  inferContentType,
23545
24749
  ingest,
24750
+ ingestDetailed,
23546
24751
  initSchema,
24752
+ interpolateMsg,
23547
24753
  isImageType,
23548
24754
  isSitePublic,
24755
+ isSkewedHlc,
23549
24756
  isTextType,
23550
24757
  keyBetween,
23551
24758
  keysBetween,
@@ -23557,10 +24764,12 @@ export {
23557
24764
  listRecords,
23558
24765
  listSites,
23559
24766
  liveFileRowIfSame,
24767
+ lookupCandidates,
23560
24768
  loopbackUiRejection,
23561
24769
  makeId,
23562
24770
  maybeAutoIndex,
23563
24771
  metahubHome,
24772
+ mhError,
23564
24773
  migrateDocBlocks,
23565
24774
  migrateDocuments,
23566
24775
  migrateNodes,
@@ -23579,6 +24788,7 @@ export {
23579
24788
  openMetahub,
23580
24789
  parseDocBlocks,
23581
24790
  parseHlc,
24791
+ persistedHlc,
23582
24792
  platformFromProcess,
23583
24793
  prepareRecordCells,
23584
24794
  prependDocument,
@@ -23595,6 +24805,7 @@ export {
23595
24805
  removeProperty,
23596
24806
  removeSelectOption,
23597
24807
  renameSelectOption,
24808
+ resetHlc,
23598
24809
  resolveCandidates,
23599
24810
  resolveData,
23600
24811
  resolveEntity,
@@ -23614,6 +24825,7 @@ export {
23614
24825
  sha256Hex,
23615
24826
  siteCacheControl,
23616
24827
  siteLifecycle,
24828
+ skewWarnings,
23617
24829
  slugify,
23618
24830
  startServer,
23619
24831
  syncWithPeer,
@@ -23631,7 +24843,8 @@ export {
23631
24843
  withChangeGroup,
23632
24844
  withNodeId,
23633
24845
  withTxnId,
23634
- writeFileRow
24846
+ writeFileRow,
24847
+ writeTx
23635
24848
  };
23636
24849
 
23637
- //# debugId=189D146DD6185A7B64756E2164756E21
24850
+ //# debugId=23C620FE660E43F464756E2164756E21