@tensorix/metahub 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +8 -4
  2. package/README.zh-CN.md +9 -5
  3. package/dist/cli.js +5539 -4193
  4. package/dist/cli.js.map +68 -61
  5. package/dist/core/audit.d.ts.map +1 -1
  6. package/dist/core/blobs-core.d.ts.map +1 -1
  7. package/dist/core/compact.d.ts.map +1 -1
  8. package/dist/core/config.d.ts.map +1 -1
  9. package/dist/core/crdt.d.ts +12 -4
  10. package/dist/core/crdt.d.ts.map +1 -1
  11. package/dist/core/databases.d.ts +3 -2
  12. package/dist/core/databases.d.ts.map +1 -1
  13. package/dist/core/db.d.ts +1 -1
  14. package/dist/core/db.d.ts.map +1 -1
  15. package/dist/core/documents.d.ts.map +1 -1
  16. package/dist/core/driver.d.ts +5 -1
  17. package/dist/core/driver.d.ts.map +1 -1
  18. package/dist/core/errors.d.ts +19 -1
  19. package/dist/core/errors.d.ts.map +1 -1
  20. package/dist/core/guest-intent.d.ts +1 -1
  21. package/dist/core/guest-intent.d.ts.map +1 -1
  22. package/dist/core/history-cache.d.ts +13 -0
  23. package/dist/core/history-cache.d.ts.map +1 -0
  24. package/dist/core/history.d.ts.map +1 -1
  25. package/dist/core/hlc.d.ts +13 -0
  26. package/dist/core/hlc.d.ts.map +1 -1
  27. package/dist/core/integrity.d.ts +19 -2
  28. package/dist/core/integrity.d.ts.map +1 -1
  29. package/dist/core/node.d.ts +52 -6
  30. package/dist/core/node.d.ts.map +1 -1
  31. package/dist/core/resolve.d.ts +8 -0
  32. package/dist/core/resolve.d.ts.map +1 -1
  33. package/dist/core/schema-init.d.ts +4 -1
  34. package/dist/core/schema-init.d.ts.map +1 -1
  35. package/dist/core/schema.d.ts +1 -1
  36. package/dist/core/schema.d.ts.map +1 -1
  37. package/dist/core/search.d.ts +1 -1
  38. package/dist/core/search.d.ts.map +1 -1
  39. package/dist/core/site-channel-store.d.ts +1 -1
  40. package/dist/core/site-channel-store.d.ts.map +1 -1
  41. package/dist/core/snapshot.d.ts.map +1 -1
  42. package/dist/core/sync/auth.d.ts +2 -4
  43. package/dist/core/sync/auth.d.ts.map +1 -1
  44. package/dist/core/sync/cf-oauth.d.ts.map +1 -1
  45. package/dist/core/sync/client.d.ts +4 -0
  46. package/dist/core/sync/client.d.ts.map +1 -1
  47. package/dist/core/sync/devices.d.ts +6 -0
  48. package/dist/core/sync/devices.d.ts.map +1 -1
  49. package/dist/core/sync/drop-protocol.d.ts.map +1 -1
  50. package/dist/core/sync/drop-pull.d.ts +1 -1
  51. package/dist/core/sync/drop-pull.d.ts.map +1 -1
  52. package/dist/core/sync/http-status.d.ts.map +1 -1
  53. package/dist/core/sync/locale.d.ts +8 -0
  54. package/dist/core/sync/locale.d.ts.map +1 -0
  55. package/dist/core/sync/page-messages.d.ts +106 -0
  56. package/dist/core/sync/page-messages.d.ts.map +1 -0
  57. package/dist/core/sync/pairing.d.ts +4 -1
  58. package/dist/core/sync/pairing.d.ts.map +1 -1
  59. package/dist/core/sync/peers-routes.d.ts.map +1 -1
  60. package/dist/core/sync/peers.d.ts +3 -1
  61. package/dist/core/sync/peers.d.ts.map +1 -1
  62. package/dist/core/sync/protocol.d.ts +1 -0
  63. package/dist/core/sync/protocol.d.ts.map +1 -1
  64. package/dist/core/sync/remote-shares-cache.d.ts +32 -0
  65. package/dist/core/sync/remote-shares-cache.d.ts.map +1 -0
  66. package/dist/core/sync/room-client.d.ts +1 -1
  67. package/dist/core/sync/room-client.d.ts.map +1 -1
  68. package/dist/core/sync/room-protocol.d.ts +1 -1
  69. package/dist/core/sync/room-protocol.d.ts.map +1 -1
  70. package/dist/core/sync/room-serve.d.ts.map +1 -1
  71. package/dist/core/sync/routes.d.ts +1 -1
  72. package/dist/core/sync/routes.d.ts.map +1 -1
  73. package/dist/core/sync/server.d.ts +2 -0
  74. package/dist/core/sync/server.d.ts.map +1 -1
  75. package/dist/core/sync/share-actions.d.ts +7 -2
  76. package/dist/core/sync/share-actions.d.ts.map +1 -1
  77. package/dist/core/sync/share-copy.d.ts +7 -0
  78. package/dist/core/sync/share-copy.d.ts.map +1 -0
  79. package/dist/core/sync/share-export.d.ts +3 -1
  80. package/dist/core/sync/share-export.d.ts.map +1 -1
  81. package/dist/core/sync/share-serve.d.ts.map +1 -1
  82. package/dist/core/sync/sites-serve.d.ts.map +1 -1
  83. package/dist/core/sync/storage.d.ts +5 -1
  84. package/dist/core/sync/storage.d.ts.map +1 -1
  85. package/dist/core/tables.d.ts +16 -0
  86. package/dist/core/tables.d.ts.map +1 -0
  87. package/dist/db-worker.js +131 -88
  88. package/dist/edge-worker.js +353 -90
  89. package/dist/index.js +2051 -471
  90. package/dist/index.js.map +57 -49
  91. package/dist/metahub-sdk.js +5 -5
  92. package/dist/mh-runtime.js +9 -1
  93. package/dist/sw.js +15 -7
  94. package/dist/webui/server/assets.d.ts.map +1 -1
  95. package/dist/webui/server/changes-route.d.ts +2 -0
  96. package/dist/webui/server/changes-route.d.ts.map +1 -1
  97. package/dist/webui/server/edge-routes.d.ts.map +1 -1
  98. package/dist/webui/server/routes.d.ts +2 -0
  99. package/dist/webui/server/routes.d.ts.map +1 -1
  100. package/dist/webui-fmt.js +1 -1
  101. package/dist/webui.js +173 -214
  102. package/package.json +1 -1
@@ -69,14 +69,38 @@ function normalizeRow(row) {
69
69
  // src/core/errors.ts
70
70
  class MhError extends Error {
71
71
  code;
72
- constructor(code, message) {
72
+ i18n;
73
+ constructor(code, message, i18n) {
73
74
  super(message);
74
75
  this.code = code;
76
+ this.i18n = i18n;
75
77
  this.name = "MhError";
76
78
  }
77
79
  }
80
+ function interpolateMsg(s, params) {
81
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
82
+ }
83
+ function mhError(code, key, params) {
84
+ return new MhError(code, interpolateMsg(key, params), { key, params });
85
+ }
86
+ var SQLITE_BUSY_RE = /^SQLITE_(BUSY|LOCKED)/;
87
+ function isSqliteBusy(e) {
88
+ if (!(e instanceof Error))
89
+ return false;
90
+ const code = e.code;
91
+ if (typeof code === "string" && SQLITE_BUSY_RE.test(code))
92
+ return true;
93
+ return e.message === "database is locked";
94
+ }
95
+ function asMhError(e) {
96
+ if (e instanceof MhError)
97
+ return e;
98
+ if (isSqliteBusy(e))
99
+ return mhError("busy", "数据库正被另一个进程写入,请稍后重试");
100
+ return null;
101
+ }
78
102
  function errorCode(e) {
79
- return e instanceof MhError ? e.code : undefined;
103
+ return asMhError(e)?.code;
80
104
  }
81
105
 
82
106
  // src/core/sync/http-status.ts
@@ -87,7 +111,9 @@ var STATUS = {
87
111
  stale: 409,
88
112
  conflict: 409,
89
113
  auth: 401,
90
- rate_limited: 429
114
+ rate_limited: 429,
115
+ busy: 503,
116
+ clock_skew: 409
91
117
  };
92
118
  function httpStatusForCode(code) {
93
119
  return code ? STATUS[code] ?? 400 : 400;
@@ -132,6 +158,12 @@ function idKind(id) {
132
158
  return KINDS.has(prefix) ? prefix : null;
133
159
  }
134
160
 
161
+ // src/core/driver.ts
162
+ function writeTx(db, fn) {
163
+ const tx = db.transaction(fn);
164
+ return (tx.immediate ?? tx)();
165
+ }
166
+
135
167
  // src/core/node.ts
136
168
  function getNodeId(db) {
137
169
  const row = db.query("SELECT value FROM meta WHERE key = 'node_id'").get();
@@ -144,6 +176,13 @@ function getNodeId(db) {
144
176
 
145
177
  // src/core/hlc.ts
146
178
  var MAX_HLC_COUNTER = 65535;
179
+ var HLC_MAX_SKEW_MS = 5 * 60000;
180
+ function hlcSkewBound(now) {
181
+ return formatHlc({ millis: now + HLC_MAX_SKEW_MS, counter: MAX_HLC_COUNTER, node: "~~~~~~~~" });
182
+ }
183
+ function isSkewedHlc(hlc, now) {
184
+ return hlc > hlcSkewBound(now);
185
+ }
147
186
  function carryCounter(millis, counter) {
148
187
  if (counter <= MAX_HLC_COUNTER)
149
188
  return { millis, counter };
@@ -174,6 +213,8 @@ function writeLast(db, h) {
174
213
  }
175
214
  function nextHlc(db, node, now = Date.now()) {
176
215
  const last = readLast(db, node);
216
+ if (last.millis > now + HLC_MAX_SKEW_MS)
217
+ throw mhError("clock_skew", "本机逻辑时钟比系统时间超前 {min} 分钟(系统时钟曾被调错,或收到过时钟错误设备的数据),运行 mh repair --clock 修复", { min: Math.ceil((last.millis - now) / 60000) });
177
218
  const millis = Math.max(last.millis, now);
178
219
  const counter = millis === last.millis ? last.counter + 1 : 0;
179
220
  const h = { ...carryCounter(millis, counter), node };
@@ -185,6 +226,8 @@ function observeHlc(db, node, remote, now = Date.now()) {
185
226
  const r = parseHlc(remote);
186
227
  if (!Number.isFinite(r.millis) || !Number.isFinite(r.counter))
187
228
  return;
229
+ if (r.millis > now + HLC_MAX_SKEW_MS)
230
+ return;
188
231
  const millis = Math.max(last.millis, r.millis, now);
189
232
  let counter;
190
233
  if (millis === last.millis && millis === r.millis)
@@ -299,6 +342,39 @@ function lcs(a, b) {
299
342
  return pairs;
300
343
  }
301
344
 
345
+ // src/core/history-cache.ts
346
+ var caches = new WeakMap;
347
+ function dropDoc(c, docId) {
348
+ const e = c.docs.get(docId);
349
+ if (!e)
350
+ return;
351
+ c.docs.delete(docId);
352
+ for (const b of e.blockIds)
353
+ if (c.blockToDoc.get(b) === docId)
354
+ c.blockToDoc.delete(b);
355
+ }
356
+ function noteChange(db, dataset, rowId, col, value) {
357
+ const c = caches.get(db);
358
+ if (!c)
359
+ return;
360
+ if (dataset === "documents") {
361
+ dropDoc(c, rowId);
362
+ return;
363
+ }
364
+ if (dataset !== "doc_blocks")
365
+ return;
366
+ const owner = c.blockToDoc.get(rowId);
367
+ if (owner)
368
+ dropDoc(c, owner);
369
+ if (col === "doc_id" && value) {
370
+ try {
371
+ const target = JSON.parse(value);
372
+ if (typeof target === "string")
373
+ dropDoc(c, target);
374
+ } catch {}
375
+ }
376
+ }
377
+
302
378
  // src/core/intent-retention.ts
303
379
  var DAY_MS = 24 * 60 * 60 * 1000;
304
380
  var DROP_ENVELOPE_RETENTION_MS = 30 * DAY_MS;
@@ -338,27 +414,27 @@ function withNodeId(node, fn) {
338
414
  currentNodeOverride = prev;
339
415
  }
340
416
  }
341
- function withChangeGroup(label, fn) {
417
+ function withChangeGroup(db, label, fn) {
342
418
  if (currentTxn !== null)
343
419
  return fn();
344
420
  currentTxn = (currentActor ? currentActor + "/" : "") + (label ? label + ":" : "") + randomSuffix(8);
345
421
  try {
346
- return fn();
422
+ return writeTx(db, fn);
347
423
  } finally {
348
424
  currentTxn = null;
349
425
  }
350
426
  }
351
- function withTxnId(txn, fn) {
427
+ function withTxnId(db, txn, fn) {
352
428
  const prev = currentTxn;
353
429
  currentTxn = txn;
354
430
  try {
355
- return fn();
431
+ return writeTx(db, fn);
356
432
  } finally {
357
433
  currentTxn = prev;
358
434
  }
359
435
  }
360
436
  function grouped(fn, label = null) {
361
- return (...args) => withChangeGroup(label, () => fn(...args));
437
+ return (db, ...args) => withChangeGroup(db, label, () => fn(db, ...args));
362
438
  }
363
439
  var DOMAIN = {
364
440
  databases: {
@@ -411,6 +487,10 @@ var DOMAIN = {
411
487
  blob_policy: {
412
488
  table: "blob_policy",
413
489
  cols: new Set(["full_nodes", "redundancy", "__deleted"])
490
+ },
491
+ nodes: {
492
+ table: "nodes",
493
+ cols: new Set(["label", "platform", "form", "app", "first_seen", "__deleted"])
414
494
  }
415
495
  };
416
496
  var RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
@@ -428,7 +508,8 @@ var NOT_NULL_ZERO_COLS = new Set([
428
508
  "sites:spa",
429
509
  "site_channels:__deleted",
430
510
  "site_files:__deleted",
431
- "blob_policy:__deleted"
511
+ "blob_policy:__deleted",
512
+ "nodes:__deleted"
432
513
  ]);
433
514
  function decodeMaterializeValue(dataset, col, valueJson) {
434
515
  if (valueJson !== null)
@@ -475,6 +556,16 @@ function materialize(db, dataset, rowId, col, valueJson) {
475
556
  }
476
557
  return;
477
558
  }
559
+ if (dataset === "databases" && col.startsWith("meta.")) {
560
+ ensureRow(db, "databases", rowId);
561
+ const key = col.slice(5);
562
+ if (valueJson === null) {
563
+ db.query(`UPDATE databases SET meta = json_remove(coalesce(meta, '{}'), '$."' || ? || '"') WHERE id = ?`).run(key, rowId);
564
+ } else {
565
+ db.query(`UPDATE databases SET meta = json_set(coalesce(meta, '{}'), '$."' || ? || '"', json(?)) WHERE id = ?`).run(key, valueJson, rowId);
566
+ }
567
+ return;
568
+ }
478
569
  const d = DOMAIN[dataset];
479
570
  if (!d || !d.cols.has(col))
480
571
  return;
@@ -492,6 +583,8 @@ function materialize(db, dataset, rowId, col, valueJson) {
492
583
  function applyChange(db, c) {
493
584
  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;
494
585
  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);
586
+ if (inserted)
587
+ noteChange(db, c.dataset, c.row_id, c.col, c.value);
495
588
  if (cur.h !== c.hlc)
496
589
  return { inserted, winner: false };
497
590
  try {
@@ -505,30 +598,41 @@ function applyChange(db, c) {
505
598
  return { inserted, winner: true };
506
599
  }
507
600
  function emit(db, dataset, rowId, col, value) {
508
- const node = currentNodeOverride ?? getNodeId(db);
509
- const change = {
510
- hlc: nextHlc(db, node),
511
- node_id: node,
512
- dataset,
513
- row_id: rowId,
514
- col,
515
- value: value === undefined ? null : JSON.stringify(value),
516
- txn: currentTxn
601
+ const mint = () => {
602
+ const node = currentNodeOverride ?? getNodeId(db);
603
+ const change = {
604
+ hlc: nextHlc(db, node),
605
+ node_id: node,
606
+ dataset,
607
+ row_id: rowId,
608
+ col,
609
+ value: value === undefined ? null : JSON.stringify(value),
610
+ txn: currentTxn
611
+ };
612
+ applyChange(db, change);
613
+ return change;
517
614
  };
518
- applyChange(db, change);
519
- return change;
615
+ return currentTxn !== null ? mint() : writeTx(db, mint);
520
616
  }
521
617
  function ingest(db, changes, opts = {}) {
618
+ return ingestDetailed(db, changes, opts).received;
619
+ }
620
+ function ingestDetailed(db, changes, opts = {}) {
522
621
  const now = opts.now ?? Date.now();
523
622
  pruneExpiredIntentReceipts(db, now);
524
623
  const node = getNodeId(db);
525
624
  let received = 0;
526
625
  let poisoned = 0;
527
- const tx = db.transaction((cs) => {
528
- for (const c of cs) {
626
+ const skewed = {};
627
+ const tx = () => {
628
+ for (const c of changes) {
529
629
  if (isExpiredIntentReceipt(c, now))
530
630
  continue;
531
- observeHlc(db, node, c.hlc);
631
+ if (isSkewedHlc(c.hlc, now)) {
632
+ skewed[c.node_id] = (skewed[c.node_id] ?? 0) + 1;
633
+ continue;
634
+ }
635
+ observeHlc(db, node, c.hlc, now);
532
636
  try {
533
637
  if (applyChange(db, c).inserted)
534
638
  received++;
@@ -537,11 +641,13 @@ function ingest(db, changes, opts = {}) {
537
641
  console.error(`[ingest] skipped a change that failed to apply (dataset=${c.dataset} col=${c.col} row=${c.row_id}): ${e.message}`);
538
642
  }
539
643
  }
540
- });
541
- tx(changes);
644
+ };
645
+ writeTx(db, tx);
542
646
  if (poisoned)
543
647
  console.error(`[ingest] ${poisoned} change(s) skipped this batch (see above)`);
544
- return received;
648
+ for (const [n, k] of Object.entries(skewed))
649
+ 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)`);
650
+ return { received, skewed };
545
651
  }
546
652
 
547
653
  // src/core/indexing.ts
@@ -1199,7 +1305,19 @@ var DATABASE_SELECT = DATABASE_COLS.join(", ");
1199
1305
  function rowOut(r) {
1200
1306
  if (!r)
1201
1307
  return null;
1202
- return { ...r, meta: r.meta === null ? null : JSON.parse(r.meta) };
1308
+ const meta = r.meta === null ? null : JSON.parse(r.meta);
1309
+ return { ...r, meta: meta && Object.keys(meta).length ? meta : null };
1310
+ }
1311
+ function emitMetaPatch(db, id, cur, patch) {
1312
+ for (const [k, v] of Object.entries(patch)) {
1313
+ if (k.includes("."))
1314
+ throw new MhError("invalid_input", `meta key must not contain ".": ${k}`);
1315
+ const next = v === undefined ? null : v;
1316
+ const prev = cur && k in cur ? cur[k] : null;
1317
+ if (JSON.stringify(prev ?? null) === JSON.stringify(next ?? null))
1318
+ continue;
1319
+ emit(db, "databases", id, `meta.${k}`, next === null ? undefined : next);
1320
+ }
1203
1321
  }
1204
1322
  var createDatabase = grouped(function createDatabase(db, opts) {
1205
1323
  const id = newId("db", opts.name);
@@ -1219,7 +1337,9 @@ var updateDatabase = grouped(function updateDatabase(db, id, fields) {
1219
1337
  if (fields.meta !== undefined) {
1220
1338
  if (fields.meta !== null && (typeof fields.meta !== "object" || Array.isArray(fields.meta)))
1221
1339
  throw new MhError("invalid_input", "meta must be a JSON object or null");
1222
- emit(db, "databases", id, "meta", fields.meta);
1340
+ const cur = getDatabase(db, id).meta;
1341
+ const patch = fields.meta ?? Object.fromEntries(Object.keys(cur ?? {}).map((k) => [k, null]));
1342
+ emitMetaPatch(db, id, cur, patch);
1223
1343
  }
1224
1344
  return getDatabase(db, id);
1225
1345
  });
@@ -1233,8 +1353,7 @@ var duplicateDatabase = grouped(function duplicateDatabase(db, id, opts = {}) {
1233
1353
  });
1234
1354
  if (src.meta !== null) {
1235
1355
  const { collapsed: _hidden, ...meta } = src.meta;
1236
- if (Object.keys(meta).length)
1237
- emit(db, "databases", dup.id, "meta", meta);
1356
+ emitMetaPatch(db, dup.id, null, meta);
1238
1357
  }
1239
1358
  const propIdMap = new Map;
1240
1359
  const selfRelNew = new Set;
@@ -1558,8 +1677,151 @@ function escapeHtml(s) {
1558
1677
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1559
1678
  }
1560
1679
 
1680
+ // src/core/sync/locale.ts
1681
+ var LANG_COOKIE = "mh-lang";
1682
+ var isLocale = (v) => v === "zh-CN" || v === "en";
1683
+ function localeFromTags(tags) {
1684
+ if (!tags || tags.length === 0)
1685
+ return "zh-CN";
1686
+ for (const tag of tags) {
1687
+ if (!tag)
1688
+ continue;
1689
+ if (/^zh\b/i.test(tag))
1690
+ return "zh-CN";
1691
+ if (/^en\b/i.test(tag))
1692
+ return "en";
1693
+ }
1694
+ return "en";
1695
+ }
1696
+ function localeFromAcceptLanguage(header) {
1697
+ if (!header)
1698
+ return "zh-CN";
1699
+ return localeFromTags(header.split(",").map((s) => s.split(";")[0].trim()));
1700
+ }
1701
+ function localeFromCookie(header) {
1702
+ if (!header)
1703
+ return null;
1704
+ for (const part of header.split(";")) {
1705
+ const eq = part.indexOf("=");
1706
+ if (eq < 0)
1707
+ continue;
1708
+ if (part.slice(0, eq).trim() !== LANG_COOKIE)
1709
+ continue;
1710
+ const v = decodeURIComponent(part.slice(eq + 1).trim());
1711
+ return isLocale(v) ? v : null;
1712
+ }
1713
+ return null;
1714
+ }
1715
+ function pickLocale(req) {
1716
+ return localeFromCookie(req.headers.get("cookie")) ?? localeFromAcceptLanguage(req.headers.get("accept-language"));
1717
+ }
1718
+
1719
+ // src/core/sync/page-messages.ts
1720
+ var EN = {
1721
+ "本机逻辑时钟比系统时间超前 {min} 分钟(系统时钟曾被调错,或收到过时钟错误设备的数据),运行 mh repair --clock 修复": "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",
1722
+ "数据库正被另一个进程写入,请稍后重试": "The database is being written by another process; retry shortly",
1723
+ "通过 metahub 分享": "Shared via metahub",
1724
+ "由 metahub 托管": "Hosted by metahub",
1725
+ "(空文档)": "(empty document)",
1726
+ 文档: "Document",
1727
+ 表格: "Table",
1728
+ 编辑: "Edit",
1729
+ 编辑文档: "Edit document",
1730
+ 保存: "Save",
1731
+ "保存中…": "Saving…",
1732
+ 保存失败: "Save failed",
1733
+ "已保存 ✓": "Saved ✓",
1734
+ "可编辑文本/数字/URL/日期单元格,失焦自动保存": "Text, number, URL and date cells are editable; changes save on blur",
1735
+ "编辑站点文件(文本文件可在线编辑;保存即对所有访问者生效)。": "Edit site files (text files can be edited here; saving applies to every visitor).",
1736
+ "(暂无文件)": "(no files yet)",
1737
+ "预览 ↗": "Preview ↗",
1738
+ "(二进制文件,不可在线编辑)": "(binary file, cannot be edited here)",
1739
+ "{name} · 文件": "{name} · Files",
1740
+ "\uD83D\uDD12 此分享受口令保护": "\uD83D\uDD12 This share is protected by a passcode",
1741
+ "口令错误,请重试。": "Wrong passcode, try again.",
1742
+ 口令: "Passcode",
1743
+ 解锁: "Unlock",
1744
+ 受保护的分享: "Protected share",
1745
+ 复制全文: "Copy all",
1746
+ 已复制: "Copied",
1747
+ 复制失败: "Copy failed",
1748
+ 站点尚未同步: "Site not synced yet",
1749
+ "分享方设备上线同步后即可访问。": "Available once the sharing device comes online and syncs.",
1750
+ 页面不存在: "Page not found",
1751
+ "这个站点里没有这个页面或文件。": "This site has no such page or file.",
1752
+ 资源暂不可用: "Resource temporarily unavailable",
1753
+ "文件内容尚未同步到房间,请稍后重试。": "The file content has not synced to the room yet. Try again later.",
1754
+ "文件的内容暂时无法取得,请稍后重试。": "The file content cannot be retrieved right now. Try again later.",
1755
+ 站点不存在: "Site not found",
1756
+ "没有名为 “{name}” 的站点。": "There is no site named “{name}”.",
1757
+ 返回站点首页: "Back to site home",
1758
+ "验证访问令牌 · metahub": "Verify access token · metahub",
1759
+ 输入访问令牌: "Enter the access token",
1760
+ "此服务器已开启访问保护。在已登录设备的「设置 → 设备与授权」获取登录链接或二维码,或在服务器上运行 {cmd}。": "This server requires an access token. Get a login link or QR code from Settings → Devices on a signed-in device, or run {cmd} on the server.",
1761
+ 粘贴访问令牌或登录链接: "Paste the access token or login link",
1762
+ 显示令牌: "Show token",
1763
+ 隐藏令牌: "Hide token",
1764
+ 解锁并进入: "Unlock and enter",
1765
+ "请输入访问令牌。": "Enter the access token.",
1766
+ "验证中…": "Verifying…",
1767
+ "令牌无效,请检查后重试。": "Invalid token. Check it and try again.",
1768
+ "登录已过期,请重新输入令牌。": "Session expired. Enter the token again.",
1769
+ 附件已失效: "Attachment unavailable",
1770
+ 链接缺少分享数据: "The link is missing share data",
1771
+ 请输入分享口令: "Enter the share passcode",
1772
+ 链接缺少密钥: "The link is missing its key",
1773
+ 口令错误或链接已失效: "Wrong passcode or the link has expired",
1774
+ 链接已失效或密钥不正确: "The link has expired or the key is wrong",
1775
+ 分享: "Share",
1776
+ 正在加载: "Loading",
1777
+ "✅ 已授权": "✅ Authorized",
1778
+ "可以关闭此标签页,回到 MetaHub 继续部署。": "You can close this tab and return to MetaHub to continue deploying.",
1779
+ 站点入口健康响应过大: "Site entry health response is too large",
1780
+ 站点入口不能指向云平台元数据服务: "Site entry must not point at a cloud metadata service",
1781
+ "Desktop sidecar 仅用于本机预览;请启动带鉴权的 mh --server,或使用 Edge": "The desktop sidecar is for local preview only; start an authenticated mh --server, or use Edge",
1782
+ "站点入口健康检查失败(HTTP {status})": "Site entry health check failed (HTTP {status})",
1783
+ 站点入口健康响应无效: "Site entry health response is invalid",
1784
+ "站点入口节点不匹配(期望 {expected},实际 {actual});这是防误配检查,不是身份认证": "Site entry node mismatch (expected {expected}, got {actual}); this is a misconfiguration guard, not authentication",
1785
+ "目标设备版本不支持定向发布渠道;请先升级该设备,再重新验证入口": "The target device does not support targeted publishing channels; upgrade it, then verify the entry again",
1786
+ "无法验证站点入口:连接超时": "Could not verify the site entry: connection timed out",
1787
+ "无法验证站点入口:{msg}": "Could not verify the site entry: {msg}",
1788
+ "请先在“设置 → 站点托管”配置并验证当前设备的公网或局域网入口": "Configure and verify this device's public or LAN entry under Settings → Site hosting first",
1789
+ "配对设备缺少节点身份,重新配对后再试": "The paired device has no node identity; pair again and retry",
1790
+ "该站点仍在等待目标设备确认回滚,请先重试回滚": "This site is still waiting for the target device to confirm the rollback; retry the rollback first",
1791
+ 配对设备同步失败: "Sync with the paired device failed",
1792
+ 没有待确认的站点回滚: "No site rollback is awaiting confirmation",
1793
+ "目标配对设备不存在,请重新配对后重试": "The target paired device does not exist; pair again and retry",
1794
+ 站点渠道已不存在: "The site channel no longer exists",
1795
+ "OAuth 流程不存在或已过期,请重新登录": "The OAuth flow does not exist or has expired; sign in again",
1796
+ "OAuth 流程不存在或已过期,请重试": "The OAuth flow does not exist or has expired; try again",
1797
+ "Cloudflare 授权失败": "Cloudflare authorization failed",
1798
+ "Cloudflare 授权尚未完成,请稍候": "Cloudflare authorization is not finished yet; please wait",
1799
+ "缺少 Cloudflare 账号 id": "Missing Cloudflare account id",
1800
+ "缺少 Cloudflare 凭据(请先登录或提供 API Token)": "Missing Cloudflare credentials (sign in first or provide an API token)",
1801
+ "Cloudflare 授权失败:{detail}": "Cloudflare authorization failed: {detail}",
1802
+ "未返回 access token": "no access token returned",
1803
+ "无法连接 Cloudflare 授权服务:{msg}": "Could not reach the Cloudflare authorization service: {msg}",
1804
+ "Cloudflare 令牌交换失败(HTTP {status})": "Cloudflare token exchange failed (HTTP {status})",
1805
+ "无法读取 Cloudflare 账号列表:{msg}": "Could not read the Cloudflare account list: {msg}",
1806
+ "读取 Cloudflare 账号失败(HTTP {status})": "Reading Cloudflare accounts failed (HTTP {status})",
1807
+ "未配置 Cloudflare OAuth 客户端,请改用 API Token 部署": "No Cloudflare OAuth client is configured; deploy with an API token instead",
1808
+ "Cloudflare 授权超时,请重试": "Cloudflare authorization timed out; try again",
1809
+ "Cloudflare 授权被拒绝:{detail}": "Cloudflare authorization was denied: {detail}",
1810
+ "OAuth state 不匹配,疑似伪造回调,已中止": "OAuth state mismatch; the callback looks forged, aborted",
1811
+ "Cloudflare 未返回授权码": "Cloudflare did not return an authorization code",
1812
+ "请输入完整的 http(s) 地址": "Enter a complete http(s) address",
1813
+ "站点入口只支持 HTTP 或 HTTPS": "Site entry supports HTTP or HTTPS only",
1814
+ "站点入口不能包含凭据、查询参数或片段": "Site entry must not contain credentials, query parameters or fragments",
1815
+ "站点入口必须是域名根地址,不能包含路径": "Site entry must be a domain root without a path",
1816
+ 站点入口必须是可访问的主机地址: "Site entry must be a reachable host address",
1817
+ "公网入口必须使用 HTTPS;HTTP 仅允许本机或局域网地址": "A public entry must use HTTPS; HTTP is only allowed for local or LAN addresses"
1818
+ };
1819
+ function pm(locale, key, params) {
1820
+ const s = locale === "en" ? EN[key] : key;
1821
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
1822
+ }
1823
+
1561
1824
  // src/core/guest-intent.ts
1562
- var HLC_SKEW_MS = 5 * 60000;
1563
1825
  var INTENT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
1564
1826
  var RECEIPT_COL = "result";
1565
1827
  function applyGuestIntent(db, policy, session, intent, opts) {
@@ -1571,7 +1833,7 @@ function applyGuestIntent(db, policy, session, intent, opts) {
1571
1833
  kind: policy.audience,
1572
1834
  guestNode: session.guestNode
1573
1835
  };
1574
- const tx = db.transaction(() => {
1836
+ return writeTx(db, () => {
1575
1837
  const prepared = prepareIntent(db, policy, principal, intent, opts.clock);
1576
1838
  const txn = intentTxnPrefix(session.guestNode, intent.intentId) + prepared.fingerprint;
1577
1839
  const receipt = readReceipt(db, session.guestNode, intent.intentId, acceptedNow);
@@ -1591,7 +1853,6 @@ function applyGuestIntent(db, policy, session, intent, opts) {
1591
1853
  return applySubmitted(db, policy, principal, intent, prepared, txn, acceptedNow);
1592
1854
  return applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow);
1593
1855
  });
1594
- return tx();
1595
1856
  }
1596
1857
  function prepareIntent(db, policy, principal, intent, clock) {
1597
1858
  if (typeof intent.payload !== "object" || intent.payload === null || Array.isArray(intent.payload))
@@ -1629,7 +1890,7 @@ function prepareIntent(db, policy, principal, intent, clock) {
1629
1890
  };
1630
1891
  }
1631
1892
  function applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow) {
1632
- return withTxnId(txn, () => withNodeId(principal.guestNode, () => {
1893
+ return withTxnId(db, txn, () => withNodeId(principal.guestNode, () => {
1633
1894
  if (intent.action === "createRecord")
1634
1895
  assertGuestCreateCapacity(db, prepared.database, policy.limits);
1635
1896
  const current = intent.action === "createRecord" ? createRecordPrepared(db, prepared.database, prepared.cells) : updateRecordPrepared(db, intent.recordId, prepared.cells);
@@ -1753,7 +2014,7 @@ function isRecordRow(value) {
1753
2014
  }
1754
2015
  function clampMillis(submittedAt, now) {
1755
2016
  const safeNow = Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
1756
- const ceil = safeNow + HLC_SKEW_MS;
2017
+ const ceil = safeNow + HLC_MAX_SKEW_MS;
1757
2018
  if (!Number.isFinite(submittedAt))
1758
2019
  return safeNow;
1759
2020
  return Math.max(0, Math.min(Math.trunc(submittedAt), ceil));
@@ -2509,6 +2770,22 @@ CREATE TABLE IF NOT EXISTS blob_policy (
2509
2770
  redundancy TEXT, -- 'all' | 'any'
2510
2771
  __deleted INTEGER NOT NULL DEFAULT 0
2511
2772
  );
2773
+
2774
+ -- Synced device roster (system tier, see tables.ts): one row per node id, written
2775
+ -- by the device itself on open (describeSelf) and by anyone who renames it.
2776
+ -- Replicates so every device shows the same name and icon for a peer — the only
2777
+ -- record a purely bucket-joined device leaves besides its oplog stream. Each
2778
+ -- column is its own LWW register, so a rename and a platform refresh never
2779
+ -- clobber each other. meta.node_label survives as a node-local read fallback.
2780
+ CREATE TABLE IF NOT EXISTS nodes (
2781
+ id TEXT PRIMARY KEY, -- node_id
2782
+ label TEXT, -- shared display name; any device may set it
2783
+ platform TEXT, -- macos | windows | linux | ios | android | web
2784
+ form TEXT, -- laptop | desktop | phone | server | browser
2785
+ app TEXT, -- cli | server | desktop | web
2786
+ first_seen INTEGER, -- epoch ms of the device's first self-description
2787
+ __deleted INTEGER NOT NULL DEFAULT 0
2788
+ );
2512
2789
  `;
2513
2790
  var FTS_SCHEMA = `
2514
2791
  CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
@@ -2769,25 +3046,6 @@ var editDocument = grouped(function editDocument(db, id, opts) {
2769
3046
  throw new MhError("invalid_input", `anchor not found: no match for --old`);
2770
3047
  if (count > 1 && !opts.replaceAll)
2771
3048
  throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
2772
- if (!opts.old.includes(`
2773
-
2774
- `)) {
2775
- const affected = blocks.filter((b) => b.text.includes(opts.old));
2776
- const next = affected.map((b) => opts.replaceAll ? b.text.replaceAll(opts.old, () => opts.new) : b.text.replace(opts.old, () => opts.new));
2777
- if (next.every((t) => t.length > 0 && !t.includes(`
2778
-
2779
- `))) {
2780
- const targets = opts.replaceAll ? affected : affected.slice(0, 1);
2781
- let wrote = false;
2782
- targets.forEach((b, i) => {
2783
- if (next[i] !== b.text) {
2784
- emit(db, "doc_blocks", b.id, "text", next[i]);
2785
- wrote = true;
2786
- }
2787
- });
2788
- return { id, changed: wrote, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
2789
- }
2790
- }
2791
3049
  const newBody = opts.replaceAll ? body.replaceAll(opts.old, () => opts.new) : body.replace(opts.old, () => opts.new);
2792
3050
  reconcileBody(db, id, newBody);
2793
3051
  return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
@@ -2884,7 +3142,7 @@ function readPolicy(db) {
2884
3142
  return { fullNodes, redundancy };
2885
3143
  }
2886
3144
  var setFullNodes = (db, nodeIds) => {
2887
- withChangeGroup(null, () => {
3145
+ withChangeGroup(db, null, () => {
2888
3146
  const seen = new Set;
2889
3147
  const list = nodeIds.filter((n) => n && !seen.has(n) && seen.add(n) != null);
2890
3148
  emit(db, "blob_policy", POLICY_ID, "full_nodes", list);
@@ -2938,7 +3196,7 @@ function migrateRecords(db) {
2938
3196
  db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
2939
3197
  if (!tableExists3(db, "record_values"))
2940
3198
  return;
2941
- const tx = db.transaction(() => {
3199
+ writeTx(db, () => {
2942
3200
  db.exec(`
2943
3201
  UPDATE records SET data = coalesce((
2944
3202
  SELECT json_group_object(rv.property_id, json(rv.value))
@@ -2949,7 +3207,6 @@ function migrateRecords(db) {
2949
3207
  db.exec("DROP TABLE record_values");
2950
3208
  backfillRecordOrderKeys(db);
2951
3209
  });
2952
- tx();
2953
3210
  backfillRecordOrderKeys(db);
2954
3211
  }
2955
3212
  function migrateShares(db) {
@@ -3007,7 +3264,7 @@ function migrateOplog(db) {
3007
3264
  function migrateCrdtChangesSeq(db) {
3008
3265
  if (hasColumn(db, "crdt_changes", "seq"))
3009
3266
  return;
3010
- const tx = db.transaction(() => {
3267
+ writeTx(db, () => {
3011
3268
  db.exec(`
3012
3269
  DROP TABLE IF EXISTS crdt_changes_new;
3013
3270
  CREATE TABLE crdt_changes_new (
@@ -3037,7 +3294,6 @@ function migrateCrdtChangesSeq(db) {
3037
3294
  if (tableExists3(db, "peers"))
3038
3295
  db.exec("UPDATE peers SET push_cursor = 0, pull_cursor = 0");
3039
3296
  });
3040
- tx();
3041
3297
  }
3042
3298
  function migrateStoragePeerUrls(db) {
3043
3299
  if (!tableExists3(db, "peers"))
@@ -3070,7 +3326,7 @@ function migrateStoragePeerUrls(db) {
3070
3326
  if (renames.length === 0)
3071
3327
  return;
3072
3328
  const map = new Map(renames.map(({ oldUrl, newUrl }) => [oldUrl, newUrl]));
3073
- const tx = db.transaction(() => {
3329
+ writeTx(db, () => {
3074
3330
  for (const { oldUrl, newUrl } of renames) {
3075
3331
  if (tableExists3(db, "storage_cursors"))
3076
3332
  db.query("UPDATE storage_cursors SET peer_url = ? WHERE peer_url = ?").run(newUrl, oldUrl);
@@ -3082,7 +3338,6 @@ function migrateStoragePeerUrls(db) {
3082
3338
  setFullNodes(db, fullNodes.map((n) => map.get(n) ?? n));
3083
3339
  }
3084
3340
  });
3085
- tx();
3086
3341
  }
3087
3342
  function migrateDatabases(db) {
3088
3343
  if (hasColumn(db, "databases", "meta"))
@@ -3125,21 +3380,27 @@ function migrateSitesAccess(db) {
3125
3380
  backfill("public_grants", "NULL");
3126
3381
  }
3127
3382
  }
3128
- var SITE_CHANNELS_REPLAY_KEY = "site_channels_replay_seq";
3129
- function migrateSiteChannels(db) {
3130
- if (!tableExists3(db, "site_channels"))
3383
+ function replayDataset(db, dataset) {
3384
+ if (!tableExists3(db, dataset))
3131
3385
  return;
3132
- const wm = db.query("SELECT value FROM meta WHERE key = ?").get(SITE_CHANNELS_REPLAY_KEY);
3386
+ const key = `${dataset}_replay_seq`;
3387
+ const wm = db.query("SELECT value FROM meta WHERE key = ?").get(key);
3133
3388
  const since = wm ? Number(wm.value) : 0;
3134
3389
  const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
3135
- WHERE dataset = 'site_channels' AND seq > ? ORDER BY seq`).all(since);
3390
+ WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
3136
3391
  if (changes.length === 0)
3137
3392
  return;
3138
- db.transaction((rows) => {
3139
- for (const change of rows)
3393
+ writeTx(db, () => {
3394
+ for (const change of changes)
3140
3395
  applyChange(db, change);
3141
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SITE_CHANNELS_REPLAY_KEY, String(rows[rows.length - 1].seq));
3142
- })(changes);
3396
+ 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));
3397
+ });
3398
+ }
3399
+ function migrateSiteChannels(db) {
3400
+ replayDataset(db, "site_channels");
3401
+ }
3402
+ function migrateNodes(db) {
3403
+ replayDataset(db, "nodes");
3143
3404
  }
3144
3405
  function initSchema(db) {
3145
3406
  runSchema(db);
@@ -3155,6 +3416,7 @@ function initSchema(db) {
3155
3416
  migrateShares(db);
3156
3417
  migrateSitesAccess(db);
3157
3418
  migrateSiteChannels(db);
3419
+ migrateNodes(db);
3158
3420
  }
3159
3421
 
3160
3422
  // src/core/sync/partition.ts
@@ -3240,12 +3502,11 @@ function roomMissingBlobs(db, limit = NEED_BLOBS_LIMIT) {
3240
3502
  return rows.map((r) => r.hash);
3241
3503
  }
3242
3504
  function roomPutBlobChunk(db, hash, idx, total, bytes) {
3243
- const tx = db.transaction(() => {
3505
+ writeTx(db, () => {
3244
3506
  if (idx === 0)
3245
3507
  db.query("DELETE FROM room_blobs WHERE hash = ?").run(hash);
3246
3508
  db.query("INSERT OR REPLACE INTO room_blobs (hash, idx, total, bytes) VALUES (?, ?, ?, ?)").run(hash, idx, total, bytes);
3247
3509
  });
3248
- tx();
3249
3510
  }
3250
3511
  function roomBlobBytes(db, hash) {
3251
3512
  const rows = db.query("SELECT idx, total, bytes FROM room_blobs WHERE hash = ? ORDER BY idx").all(hash);
@@ -3275,6 +3536,7 @@ function evictRow(db, key, ackSeq, guestBase) {
3275
3536
  if (!table)
3276
3537
  return;
3277
3538
  db.query(`DELETE FROM ${table} WHERE id = ?`).run(key.row_id);
3539
+ noteChange(db, key.dataset, key.row_id, "", null);
3278
3540
  db.query(`DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ?
3279
3541
  AND NOT (${GUEST_NODE_SQL} AND seq > ?)`).run(key.dataset, key.row_id, guestBase, guestBase, ackSeq);
3280
3542
  const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(key.dataset, key.row_id) != null;
@@ -3291,6 +3553,7 @@ function sweepEvictPending(db, ackSeq, guestBase) {
3291
3553
  db.query("DELETE FROM evict_pending WHERE dataset = ? AND row_id = ?").run(k.dataset, k.row_id);
3292
3554
  continue;
3293
3555
  }
3556
+ noteChange(db, k.dataset, k.row_id, "", null);
3294
3557
  db.query(`DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ? AND seq <= ? AND (${GUEST_NODE_SQL})`).run(k.dataset, k.row_id, ackSeq, guestBase, guestBase);
3295
3558
  const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(k.dataset, k.row_id) != null;
3296
3559
  if (!remaining)
@@ -3624,9 +3887,9 @@ function createRoomFetch(deps) {
3624
3887
  if (req.method !== "GET")
3625
3888
  return plain404();
3626
3889
  if (locked) {
3627
- return wantsHtml(req) ? new Response(unlockPage(cfg, false), { headers: HTML }) : json({ error: "unauthorized" }, 401);
3890
+ return wantsHtml(req) ? new Response(unlockPage(pickLocale(req), cfg, false), { headers: HTML }) : json({ error: "unauthorized" }, 401);
3628
3891
  }
3629
- return serveRoomFile(db, cfg, sub);
3892
+ return serveRoomFile(db, cfg, sub, pickLocale(req));
3630
3893
  };
3631
3894
  }
3632
3895
  async function handleUnlock(db, cfg, req, url) {
@@ -3640,20 +3903,20 @@ async function handleUnlock(db, cfg, req, url) {
3640
3903
  }
3641
3904
  const ok = !cfg.pwHash || !cfg.pwSalt ? true : await verifyPasswordVerifier(cfg.pwHash, cfg.pwSalt, pw);
3642
3905
  if (!ok)
3643
- return new Response(unlockPage(cfg, true), { headers: HTML, status: 401 });
3906
+ return new Response(unlockPage(pickLocale(req), cfg, true), { headers: HTML, status: 401 });
3644
3907
  const minted = await mintRoomSession(db, cfg, url);
3645
3908
  return new Response(null, {
3646
3909
  status: 303,
3647
3910
  headers: { location: `/r/${cfg.slug}/`, "set-cookie": minted.cookie }
3648
3911
  });
3649
3912
  }
3650
- function serveRoomFile(db, cfg, path) {
3913
+ function serveRoomFile(db, cfg, path, locale) {
3651
3914
  const site = db.query("SELECT id, spa FROM sites WHERE __deleted = 0 ORDER BY created_hlc LIMIT 1").get();
3652
3915
  if (!site)
3653
- return roomMessagePage("站点尚未同步", "分享方设备上线同步后即可访问。", 404);
3916
+ return roomMessagePage(locale, pm(locale, "站点尚未同步"), pm(locale, "分享方设备上线同步后即可访问。"), 404);
3654
3917
  const resolved = resolveSiteFileRow(db, site.id, path, { spa: site.spa === 1 });
3655
3918
  if (!resolved)
3656
- return roomMessagePage("页面不存在", "这个站点里没有这个页面或文件。", 404);
3919
+ return roomMessagePage(locale, pm(locale, "页面不存在"), pm(locale, "这个站点里没有这个页面或文件。"), 404);
3657
3920
  const { row, status } = resolved;
3658
3921
  const headers = {
3659
3922
  "content-type": row.content_type,
@@ -3669,7 +3932,7 @@ function serveRoomFile(db, cfg, path) {
3669
3932
  }
3670
3933
  const bytes = row.content ? roomBlobBytes(db, row.content) : null;
3671
3934
  if (!bytes)
3672
- return roomMessagePage("资源暂不可用", "文件内容尚未同步到房间,请稍后重试。", 404);
3935
+ return roomMessagePage(locale, pm(locale, "资源暂不可用"), pm(locale, "文件内容尚未同步到房间,请稍后重试。"), 404);
3673
3936
  const body = new Uint8Array(bytes.byteLength);
3674
3937
  body.set(bytes);
3675
3938
  return new Response(body.buffer, { status, headers });
@@ -3688,28 +3951,28 @@ var PAGE_CSS = `
3688
3951
  .muted{color:var(--muted);font-size:14px}
3689
3952
  h1{font-size:20px;margin:0 0 10px;text-align:center}
3690
3953
  footer{margin-top:34px;color:var(--muted);font-size:12px;text-align:center}`;
3691
- function unlockPage(cfg, error) {
3692
- return `<!doctype html><html lang="zh"><head>
3954
+ function unlockPage(locale, cfg, error) {
3955
+ return `<!doctype html><html lang="${locale}"><head>
3693
3956
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3694
- <meta name="robots" content="noindex"><title>受保护的分享</title>
3957
+ <meta name="robots" content="noindex"><title>${escapeHtml(pm(locale, "受保护的分享"))}</title>
3695
3958
  <style>${PAGE_CSS}</style></head><body><main>
3696
3959
  <form method="post" action="/r/${escapeHtml(cfg.slug)}/unlock" class="pw">
3697
- <h1>\uD83D\uDD12 此分享受口令保护</h1>
3698
- ${error ? '<p class="err">口令错误,请重试。</p>' : ""}
3699
- <input type="password" name="password" placeholder="口令" autofocus autocomplete="current-password">
3700
- <button type="submit">解锁</button>
3960
+ <h1>${escapeHtml(pm(locale, "\uD83D\uDD12 此分享受口令保护"))}</h1>
3961
+ ${error ? `<p class="err">${escapeHtml(pm(locale, "口令错误,请重试。"))}</p>` : ""}
3962
+ <input type="password" name="password" placeholder="${escapeHtml(pm(locale, "口令"))}" autofocus autocomplete="current-password">
3963
+ <button type="submit">${escapeHtml(pm(locale, "解锁"))}</button>
3701
3964
  </form>
3702
- <footer>通过 metahub 分享</footer>
3965
+ <footer>${escapeHtml(pm(locale, "通过 metahub 分享"))}</footer>
3703
3966
  </main></body></html>`;
3704
3967
  }
3705
- function roomMessagePage(title, detail, status) {
3706
- const html = `<!doctype html><html lang="zh"><head>
3968
+ function roomMessagePage(locale, title, detail, status) {
3969
+ const html = `<!doctype html><html lang="${locale}"><head>
3707
3970
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3708
3971
  <meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
3709
3972
  <style>${PAGE_CSS}</style></head><body><main>
3710
3973
  <h1>${escapeHtml(title)}</h1>
3711
3974
  <p class="muted" style="text-align:center">${escapeHtml(detail)}</p>
3712
- <footer>由 metahub 托管</footer>
3975
+ <footer>${escapeHtml(pm(locale, "由 metahub 托管"))}</footer>
3713
3976
  </main></body></html>`;
3714
3977
  return new Response(html, { status, headers: { ...HTML, "cache-control": "no-store" } });
3715
3978
  }