@chance722/dsh-inbox 0.2.2 → 0.2.4

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.
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
3
3
 
4
4
  // src/shared/constants.ts
5
5
  var PACKAGE_NAME = "@chance722/dsh-inbox";
6
- var VERSION = true ? "0.2.2" : "0.0.0-dev";
6
+ var VERSION = true ? "0.2.4" : "0.0.0-dev";
7
7
  var DEFAULT_USER_AGENT = "dsh-inbox";
8
8
 
9
9
  // src/shared/vocabulary.ts
@@ -749,6 +749,7 @@ var KEY_BLOCK = /<Key>([\s\S]*?)<\/Key>/i;
749
749
  var LAST_MODIFIED = /<LastModified>([\s\S]*?)<\/LastModified>/i;
750
750
  var SIZE = /<Size>([\s\S]*?)<\/Size>/i;
751
751
  var CONTENTS = /<Contents>[\s\S]*?<\/Contents>/gi;
752
+ var COMMON_PREFIX = /<CommonPrefixes>[\s\S]*?<Prefix>([\s\S]*?)<\/Prefix>[\s\S]*?<\/CommonPrefixes>/gi;
752
753
  function decode(value) {
753
754
  return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&").trim();
754
755
  }
@@ -768,15 +769,15 @@ function parseListing(xml) {
768
769
  }
769
770
  return objects;
770
771
  }
771
- async function listPrefix(config, prefix, deps) {
772
+ async function listPrefix(config, prefix, deps, extra = {}) {
772
773
  const sign = signer(config);
773
- const v2 = sign(config, deps, "GET", "", { "list-type": "2", prefix });
774
+ const v2 = sign(config, deps, "GET", "", { "list-type": "2", prefix, ...extra });
774
775
  const first = await deps.fetch(v2.url, { method: "GET", headers: v2.headers });
775
776
  if (first.ok) return parseListing(await first.text());
776
777
  const refusal2 = await refused(first, "\u5217\u5BF9\u8C61");
777
778
  if (first.status < 500) {
778
779
  if (config.signatureVersion?.toLowerCase() !== "v2" && first.status === 401) {
779
- const minimal = signRequestV4Minimal(config, deps, "GET", "", { "list-type": "2", prefix });
780
+ const minimal = signRequestV4Minimal(config, deps, "GET", "", { "list-type": "2", prefix, ...extra });
780
781
  const retry = await deps.fetch(minimal.url, { method: "GET", headers: minimal.headers });
781
782
  if (retry.ok) return parseListing(await retry.text());
782
783
  throw new Error(
@@ -786,7 +787,7 @@ async function listPrefix(config, prefix, deps) {
786
787
  }
787
788
  throw refusal2;
788
789
  }
789
- const v1 = sign(config, deps, "GET", "", { prefix });
790
+ const v1 = sign(config, deps, "GET", "", { prefix, ...extra });
790
791
  const second = await deps.fetch(v1.url, { method: "GET", headers: v1.headers });
791
792
  if (!second.ok) {
792
793
  throw new Error(`${await refused(second, "\u5217\u5BF9\u8C61\uFF08V1 \u56DE\u9000\uFF09").then((e) => e.message)}
@@ -794,6 +795,27 @@ async function listPrefix(config, prefix, deps) {
794
795
  }
795
796
  return parseListing(await second.text());
796
797
  }
798
+ async function listTopLevel(config, deps) {
799
+ const sign = signer(config);
800
+ const v2 = sign(config, deps, "GET", "", { "list-type": "2", delimiter: "/", "max-keys": "100" });
801
+ const first = await deps.fetch(v2.url, { method: "GET", headers: v2.headers });
802
+ if (first.ok) return parsePrefixes(await first.text());
803
+ if (first.status >= 500) {
804
+ const v1 = sign(config, deps, "GET", "", { delimiter: "/", "max-keys": "100" });
805
+ const second = await deps.fetch(v1.url, { method: "GET", headers: v1.headers });
806
+ if (second.ok) return parsePrefixes(await second.text());
807
+ }
808
+ return [];
809
+ }
810
+ function parsePrefixes(xml) {
811
+ const prefixes = [];
812
+ for (const match of xml.matchAll(COMMON_PREFIX)) {
813
+ const value = decode(match[1] ?? "");
814
+ if (value.length === 0) continue;
815
+ prefixes.push(value.replace(/\/$/, ""));
816
+ }
817
+ return prefixes;
818
+ }
797
819
  async function readObject(config, key, deps) {
798
820
  const signed = signer(config)(config, deps, "GET", key);
799
821
  const response = await deps.fetch(signed.url, { method: "GET", headers: signed.headers });
@@ -933,6 +955,42 @@ async function readFile(path, deps, absoluteUrl) {
933
955
  return { bytes, contentType: declared.split(";")[0] ?? "application/octet-stream" };
934
956
  }
935
957
 
958
+ // src/shared/panel-wire.ts
959
+ var INBOX_API_PREFIX = "/api/inbox";
960
+ var INBOX_ENDPOINT_CAPTURE = "capture";
961
+ var INBOX_ENDPOINT_LIST = "list";
962
+ var INBOX_ENDPOINT_DETAIL = "detail";
963
+ var INBOX_ENDPOINT_UPDATE = "update";
964
+ var INBOX_ENDPOINT_DELETE = "delete";
965
+ var INBOX_ENDPOINT_RESTORE = "restore";
966
+ var INBOX_ENDPOINT_PURGE = "purge";
967
+ var INBOX_ENDPOINT_ATTACHMENT = "attachment";
968
+ var INBOX_ENDPOINT_WEBDAV = "webdav";
969
+ var INBOX_ENDPOINT_PULL = "pull";
970
+ var INBOX_ENDPOINT_PROBE = "probe";
971
+ var INBOX_ENDPOINT_UI = "ui";
972
+ var INBOX_ENDPOINT_TAGS = "tags";
973
+ var INBOX_ENDPOINT_SECRET = "secret";
974
+ var INBOX_ENDPOINT_PUSH = "push";
975
+ var DEFAULT_SYNC_DIRECTORY = "inbox";
976
+ function syncDirectory(raw) {
977
+ const trimmed = (raw ?? "").trim().replace(/^\/+|\/+$/g, "");
978
+ return trimmed.length === 0 ? DEFAULT_SYNC_DIRECTORY : trimmed;
979
+ }
980
+ function syncRootFor(raw) {
981
+ return `${syncDirectory(raw)}/sync`;
982
+ }
983
+ var INBOX_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
984
+ var LIST_LIMIT = 50;
985
+ var UI_LIST_MODES = ["grid", "compact"];
986
+ var PREVIEW_CHARS = 140;
987
+ var MAX_ATTACHMENTS_PER_SUBMISSION = 20;
988
+ var MAX_NOTE_CHARS = 2e3;
989
+ var MAX_TAGS = 20;
990
+ var MAX_TAG_CHARS = 40;
991
+ var MAX_FILTER_CHARS = 200;
992
+ var MAX_TITLE_CHARS = 300;
993
+
936
994
  // src/host/webdav/config.ts
937
995
  import z from "@deepseek-ai/schemastery";
938
996
  var SETTINGS_NAMESPACE = "dsh-inbox-webdav";
@@ -942,6 +1000,7 @@ var DEFAULT_SETTINGS = {
942
1000
  protocol: "webdav",
943
1001
  baseUrl: "",
944
1002
  directory: "/inbox",
1003
+ adoptForeignRoots: false,
945
1004
  username: "",
946
1005
  endpoint: "",
947
1006
  bucket: "",
@@ -955,6 +1014,7 @@ var WebdavSettingsSchema = z.object({
955
1014
  protocol: z.union(["webdav", "s3"]).default("webdav"),
956
1015
  baseUrl: z.string().default(""),
957
1016
  directory: z.string().default("/inbox"),
1017
+ adoptForeignRoots: z.boolean().default(false),
958
1018
  username: z.string().default(""),
959
1019
  endpoint: z.string().default(""),
960
1020
  bucket: z.string().default(""),
@@ -1026,6 +1086,7 @@ async function saveWebdav(ctx, vault, base, patch) {
1026
1086
  const directory = patch.directory.trim();
1027
1087
  config.directory = directory.startsWith("/") ? directory : `/${directory}`;
1028
1088
  }
1089
+ if (patch.adoptForeignRoots !== void 0) config.adoptForeignRoots = patch.adoptForeignRoots;
1029
1090
  if (patch.username !== void 0) config.username = patch.username.trim();
1030
1091
  if (patch.endpoint !== void 0) config.endpoint = normalizeUrl(patch.endpoint);
1031
1092
  if (patch.bucket !== void 0) config.bucket = patch.bucket.trim();
@@ -1070,36 +1131,6 @@ async function saveWebdav(ctx, vault, base, patch) {
1070
1131
 
1071
1132
  // src/host/remote/pull.ts
1072
1133
  import { admitEncodedFile, admitEncodedImages } from "@deepseek-ai/dsh-attachment";
1073
-
1074
- // src/shared/panel-wire.ts
1075
- var INBOX_API_PREFIX = "/api/inbox";
1076
- var INBOX_ENDPOINT_CAPTURE = "capture";
1077
- var INBOX_ENDPOINT_LIST = "list";
1078
- var INBOX_ENDPOINT_DETAIL = "detail";
1079
- var INBOX_ENDPOINT_UPDATE = "update";
1080
- var INBOX_ENDPOINT_DELETE = "delete";
1081
- var INBOX_ENDPOINT_RESTORE = "restore";
1082
- var INBOX_ENDPOINT_PURGE = "purge";
1083
- var INBOX_ENDPOINT_ATTACHMENT = "attachment";
1084
- var INBOX_ENDPOINT_WEBDAV = "webdav";
1085
- var INBOX_ENDPOINT_PULL = "pull";
1086
- var INBOX_ENDPOINT_PROBE = "probe";
1087
- var INBOX_ENDPOINT_UI = "ui";
1088
- var INBOX_ENDPOINT_TAGS = "tags";
1089
- var INBOX_ENDPOINT_SECRET = "secret";
1090
- var INBOX_ENDPOINT_PUSH = "push";
1091
- var INBOX_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
1092
- var LIST_LIMIT = 50;
1093
- var UI_LIST_MODES = ["grid", "compact"];
1094
- var PREVIEW_CHARS = 140;
1095
- var MAX_ATTACHMENTS_PER_SUBMISSION = 20;
1096
- var MAX_NOTE_CHARS = 2e3;
1097
- var MAX_TAGS = 20;
1098
- var MAX_TAG_CHARS = 40;
1099
- var MAX_FILTER_CHARS = 200;
1100
- var MAX_TITLE_CHARS = 300;
1101
-
1102
- // src/host/remote/pull.ts
1103
1134
  var DEFAULT_DIRECTORY = "/inbox";
1104
1135
  var TEXT_TYPES = ["text/", "application/json"];
1105
1136
  var TEXT_SUFFIXES = [".txt", ".md", ".url", ".json"];
@@ -1108,8 +1139,11 @@ function looksTextual(name2, contentType) {
1108
1139
  if (TEXT_SUFFIXES.some((suffix) => lower.endsWith(suffix))) return true;
1109
1140
  return contentType !== void 0 && TEXT_TYPES.some((prefix) => contentType.startsWith(prefix));
1110
1141
  }
1111
- function isSyncObject(path) {
1112
- return path.split("/").some((part) => part === "sync");
1142
+ function syncPrefixOf(path) {
1143
+ const parts = path.split("/").filter((part) => part.length > 0);
1144
+ const at = parts.lastIndexOf("sync");
1145
+ if (at === -1) return void 0;
1146
+ return parts.slice(0, at + 1).slice(-2).join("/");
1113
1147
  }
1114
1148
  function laterOf(current, candidate) {
1115
1149
  if (candidate === void 0) return current;
@@ -1128,7 +1162,7 @@ function isNewer(entry, lastPullAt) {
1128
1162
  if (Number.isNaN(seen) || Number.isNaN(remote)) return true;
1129
1163
  return remote > seen;
1130
1164
  }
1131
- async function ingestFrom(vault, source, attachments) {
1165
+ async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(void 0), elsewhere = { roots: [] }) {
1132
1166
  const lastPullAt = vault.global.sync.lastPullAt;
1133
1167
  let entries;
1134
1168
  try {
@@ -1141,15 +1175,40 @@ async function ingestFrom(vault, source, attachments) {
1141
1175
  let pulled = 0;
1142
1176
  let failed2 = 0;
1143
1177
  let skipped = 0;
1178
+ let skippedSync = 0;
1179
+ let skippedOlder = 0;
1180
+ let skippedForeign = 0;
1181
+ let remoteRecords = 0;
1182
+ let remoteAttachments = 0;
1183
+ const foreignSyncRoots = new Set(elsewhere.roots);
1184
+ let foreignRecords = elsewhere.records ?? 0;
1144
1185
  let newestSuccess;
1145
1186
  const failures = [];
1146
1187
  for (const entry of entries) {
1147
- if (isSyncObject(entry.path)) {
1188
+ const prefix = syncPrefixOf(entry.path);
1189
+ if (prefix !== void 0 && prefix !== syncRoot3) {
1190
+ skipped += 1;
1191
+ skippedForeign += 1;
1192
+ foreignSyncRoots.add(prefix);
1193
+ const parts = entry.path.split("/").filter((part) => part.length > 0);
1194
+ if ((parts[parts.length - 2] ?? "") === "items" && (parts[parts.length - 1] ?? "").endsWith(".json")) {
1195
+ foreignRecords += 1;
1196
+ }
1197
+ continue;
1198
+ }
1199
+ if (prefix !== void 0) {
1148
1200
  skipped += 1;
1201
+ skippedSync += 1;
1202
+ const parts = entry.path.split("/").filter((part) => part.length > 0);
1203
+ const folder = parts[parts.length - 2] ?? "";
1204
+ const name3 = parts[parts.length - 1] ?? "";
1205
+ if (folder === "items" && name3.endsWith(".json")) remoteRecords += 1;
1206
+ else if (folder === "attachments" && !name3.endsWith(".meta.json")) remoteAttachments += 1;
1149
1207
  continue;
1150
1208
  }
1151
1209
  if (!isNewer(entry, lastPullAt)) {
1152
1210
  skipped += 1;
1211
+ skippedOlder += 1;
1153
1212
  continue;
1154
1213
  }
1155
1214
  const name2 = nameOf(entry.path);
@@ -1207,6 +1266,14 @@ async function ingestFrom(vault, source, attachments) {
1207
1266
  pulled,
1208
1267
  failed: failed2,
1209
1268
  skipped,
1269
+ skippedSync,
1270
+ skippedOlder,
1271
+ skippedForeign,
1272
+ remoteRecords,
1273
+ remoteAttachments,
1274
+ syncRoot: syncRoot3,
1275
+ ...foreignSyncRoots.size === 0 ? {} : { foreignSyncRoots: [...foreignSyncRoots] },
1276
+ ...foreignRecords === 0 ? {} : { foreignRecords },
1210
1277
  listed,
1211
1278
  lastPullAt: cursor,
1212
1279
  ...failures.length === 0 ? {} : { reason: failures.slice(0, 3).join("\uFF1B") }
@@ -1224,7 +1291,7 @@ async function pullRemote(vault, config, deps) {
1224
1291
  listed: 0
1225
1292
  };
1226
1293
  }
1227
- const directory = config.directory ?? DEFAULT_DIRECTORY;
1294
+ const directory = `/${syncDirectory(config.directory ?? DEFAULT_DIRECTORY)}`;
1228
1295
  return ingestFrom(
1229
1296
  vault,
1230
1297
  {
@@ -1235,10 +1302,12 @@ async function pullRemote(vault, config, deps) {
1235
1302
  })),
1236
1303
  read: async (entry) => readFile(entry.path, deps, entry.path.startsWith("http") ? entry.path : void 0)
1237
1304
  },
1238
- deps.attachments
1305
+ deps.attachments,
1306
+ // WebDAV resolves the sync root from the same directory rule the writer uses.
1307
+ syncRootFor(config.directory)
1239
1308
  );
1240
1309
  }
1241
- async function pullS3(vault, config, prefix, deps) {
1310
+ async function pullS3(vault, config, directory, deps) {
1242
1311
  if (config.endpoint.trim().length === 0 || config.bucket.trim().length === 0) {
1243
1312
  return {
1244
1313
  status: "unconfigured",
@@ -1249,18 +1318,44 @@ async function pullS3(vault, config, prefix, deps) {
1249
1318
  listed: 0
1250
1319
  };
1251
1320
  }
1321
+ const scope = syncDirectory(directory);
1252
1322
  return ingestFrom(
1253
1323
  vault,
1254
1324
  {
1255
- list: async () => (await listPrefix(config, prefix, deps)).map((object) => ({
1325
+ list: async () => (await listPrefix(config, `${scope}/`, deps)).map((object) => ({
1256
1326
  path: object.key,
1257
1327
  ...object.lastModified === void 0 ? {} : { lastModified: object.lastModified }
1258
1328
  })),
1259
1329
  read: async (entry) => readObject(config, entry.path, deps)
1260
1330
  },
1261
- deps.attachments
1331
+ deps.attachments,
1332
+ // The vault's own tree, resolved by the same rule the writer uses.
1333
+ syncRootFor(directory),
1334
+ await s3RootsElsewhere(config, deps, scope)
1262
1335
  );
1263
1336
  }
1337
+ var PROBE_CANDIDATES = 5;
1338
+ async function s3RootsElsewhere(config, deps, scope) {
1339
+ try {
1340
+ const tops = await listTopLevel(config, deps);
1341
+ const found = [];
1342
+ let records = 0;
1343
+ for (const top of tops.slice(0, PROBE_CANDIDATES)) {
1344
+ if (top === scope || top.startsWith(`${scope}/`)) continue;
1345
+ for (const probe2 of [`${top}/sync/items/`, `${top}/items/`]) {
1346
+ const inside = await listPrefix(config, probe2, deps, { "max-keys": "1000" });
1347
+ if (inside.length > 0) {
1348
+ found.push(probe2.replace(/\/items\/$/, ""));
1349
+ records += inside.filter((object) => object.key.endsWith(".json")).length;
1350
+ break;
1351
+ }
1352
+ }
1353
+ }
1354
+ return found.length === 0 ? { roots: [] } : { roots: found, records };
1355
+ } catch {
1356
+ return { roots: [] };
1357
+ }
1358
+ }
1264
1359
 
1265
1360
  // src/host/remote/merge.ts
1266
1361
  import { admitEncodedFile as admitEncodedFile2, admitEncodedImages as admitEncodedImages2 } from "@deepseek-ai/dsh-attachment";
@@ -1308,13 +1403,14 @@ function wins(remote, local) {
1308
1403
  async function mergeOnce(vault, tree, prefix, admit) {
1309
1404
  const failures = [];
1310
1405
  let merged = 0;
1406
+ let added = 0;
1311
1407
  let kept = 0;
1312
1408
  let attachments = 0;
1313
1409
  let objects;
1314
1410
  try {
1315
1411
  objects = await tree.list(`${prefix}/items/`);
1316
1412
  } catch (error) {
1317
- return { merged: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1413
+ return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1318
1414
  }
1319
1415
  const wanted = /* @__PURE__ */ new Map();
1320
1416
  const bytesAt = /* @__PURE__ */ new Map();
@@ -1344,6 +1440,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1344
1440
  }
1345
1441
  await vault.import(remote);
1346
1442
  merged += 1;
1443
+ if (local === void 0) added += 1;
1347
1444
  for (const attachmentId of remote.attachmentIds) {
1348
1445
  if (vault.getAttachment(attachmentId) === void 0) {
1349
1446
  wanted.set(attachmentId, remote.id);
@@ -1378,7 +1475,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1378
1475
  failures.push(`\u9644\u4EF6 ${attachmentId}\uFF1A${reasonOf(error)}`);
1379
1476
  }
1380
1477
  }
1381
- return { merged, kept, attachments, failures };
1478
+ return { merged, added, kept, attachments, failures };
1382
1479
  }
1383
1480
  function extensionOfName(path) {
1384
1481
  return /\.([A-Za-z0-9]{1,8})$/.exec(nameOf2(path))?.[1]?.toLowerCase() ?? "bin";
@@ -1386,15 +1483,26 @@ function extensionOfName(path) {
1386
1483
  function reasonOf(error) {
1387
1484
  return error instanceof Error ? error.message : String(error);
1388
1485
  }
1389
- async function mergeRemote(ctx, vault, attachments) {
1486
+ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1390
1487
  const settings = readSettings(ctx);
1391
1488
  const prefix = syncRoot(settings);
1392
1489
  try {
1393
1490
  const tree = settings.protocol === "s3" ? await s3Tree(ctx, settings) : await webdavTree(ctx, settings);
1394
- if (tree === void 0) return { merged: 0, kept: 0, attachments: 0, failures: [] };
1395
- return await mergeOnce(vault, tree, prefix, admitWith(attachments));
1491
+ if (tree === void 0) return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [] };
1492
+ const admit = admitWith(attachments);
1493
+ const outcome = await mergeOnce(vault, tree, prefix, admit);
1494
+ for (const root of extraRoots) {
1495
+ if (root === prefix) continue;
1496
+ const extra = await mergeOnce(vault, tree, root, admit);
1497
+ outcome.merged += extra.merged;
1498
+ outcome.added += extra.added;
1499
+ outcome.kept += extra.kept;
1500
+ outcome.attachments += extra.attachments;
1501
+ outcome.failures.push(...extra.failures);
1502
+ }
1503
+ return outcome;
1396
1504
  } catch (error) {
1397
- return { merged: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1505
+ return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1398
1506
  }
1399
1507
  }
1400
1508
  function admitWith(store) {
@@ -1493,11 +1601,15 @@ async function runPull(ctx, vault, attachments) {
1493
1601
  }
1494
1602
  async function withMerge(ctx, vault, attachments, pulled) {
1495
1603
  if (pulled.status !== "ok" || attachments === void 0) return pulled;
1496
- const outcome = await mergeRemote(ctx, vault, attachments);
1604
+ const settings = readSettings(ctx);
1605
+ const extraRoots = settings.adoptForeignRoots ? pulled.foreignSyncRoots ?? [] : [];
1606
+ const outcome = await mergeRemote(ctx, vault, attachments, extraRoots);
1497
1607
  const troubles = [...pulled.failed > 0 && pulled.reason !== void 0 ? [pulled.reason] : [], ...outcome.failures];
1498
1608
  return {
1499
1609
  ...pulled,
1500
1610
  merged: outcome.merged,
1611
+ added: outcome.added,
1612
+ kept: outcome.kept,
1501
1613
  attachments: outcome.attachments,
1502
1614
  failed: pulled.failed + outcome.failures.length,
1503
1615
  ...troubles.length === 0 ? {} : { reason: troubles.slice(0, 3).join("\uFF1B") }
@@ -1546,7 +1658,10 @@ async function pullDropFolder(ctx, vault, attachments) {
1546
1658
  signatureVersion: settings.signatureVersion,
1547
1659
  userAgent: activeUserAgent(settings)
1548
1660
  },
1549
- settings.directory.replace(/^\//, ""),
1661
+ // The configured directory, not a pre-trimmed string: `/`, `''` and
1662
+ // "unset" all mean the default, and the ingest resolves the vault's own
1663
+ // root from the same rule the writer uses.
1664
+ settings.directory,
1550
1665
  {
1551
1666
  fetch: s3Fetch,
1552
1667
  accessKeyId: settings.accessKeyId,
@@ -1570,8 +1685,7 @@ async function pullDropFolder(ctx, vault, attachments) {
1570
1685
 
1571
1686
  // src/host/remote/writer.ts
1572
1687
  function syncRoot(settings) {
1573
- const prefix = settings.directory.replace(/^\/+|\/+$/g, "");
1574
- return prefix.length === 0 ? "sync" : `${prefix}/sync`;
1688
+ return syncRootFor(settings.directory);
1575
1689
  }
1576
1690
  async function remoteWriter(ctx) {
1577
1691
  const settings = readSettings(ctx);
@@ -3072,6 +3186,9 @@ async function handleWebdav(ctx, vault, payload) {
3072
3186
  if (typeof request.protocol === "string") patch.protocol = request.protocol;
3073
3187
  if (typeof request.baseUrl === "string") patch.baseUrl = request.baseUrl;
3074
3188
  if (typeof request.directory === "string") patch.directory = request.directory;
3189
+ if (typeof request.adoptForeignRoots === "boolean") {
3190
+ patch.adoptForeignRoots = request.adoptForeignRoots;
3191
+ }
3075
3192
  if (typeof request.username === "string") patch.username = request.username;
3076
3193
  if (typeof request.password === "string") patch.password = request.password;
3077
3194
  if (typeof request.endpoint === "string") patch.endpoint = request.endpoint;
@@ -129,6 +129,9 @@ export declare const zh: {
129
129
  'settings.region': string;
130
130
  'settings.bucketAddress': string;
131
131
  'settings.bucketDir': string;
132
+ 'settings.adoptForeign': string;
133
+ 'settings.adoptForeignHint': string;
134
+ 'settings.directoryPlaceholder': string;
132
135
  'settings.dirS3.lead': string;
133
136
  'settings.dirS3.mid': string;
134
137
  'settings.dirS3.tail': string;
@@ -144,6 +147,7 @@ export declare const zh: {
144
147
  'settings.save': string;
145
148
  'settings.probe': string;
146
149
  'settings.details': string;
150
+ 'settings.syncRootNow': string;
147
151
  'settings.noServiceBody': string;
148
152
  'settings.bucketHint': string;
149
153
  'notice.listFailed': string;
@@ -175,16 +179,20 @@ export declare const zh: {
175
179
  'sync.pushUnconfigured': string;
176
180
  'sync.pushFailed': string;
177
181
  'sync.pushUnknown': string;
178
- 'sync.pushUpToDate': string;
179
- 'sync.pushDone': string;
180
182
  'sync.pushPartial': string;
181
183
  'sync.pullNoAddress': string;
182
184
  'sync.pullFailed': string;
183
- 'sync.pullNothingNew': string;
184
- 'sync.pullMerged': string;
185
- 'sync.pullAttachments': string;
186
- 'sync.pullDone': string;
187
- 'sync.pullFailedCount': string;
185
+ 'sync.detailPushedIdle': string;
186
+ 'sync.detailPushed': string;
187
+ 'sync.detailPulled': string;
188
+ 'sync.detailAdded': string;
189
+ 'sync.detailSkip': string;
190
+ 'sync.pullForeignSync': string;
191
+ 'sync.detailForeign': string;
192
+ 'sync.shortPushed': string;
193
+ 'sync.shortPulled': string;
194
+ 'sync.shortIdle': string;
195
+ 'sync.pullFailures': string;
188
196
  'sync.thisPage': string;
189
197
  'settings.status': string;
190
198
  'settings.statusOn': string;
@@ -250,10 +258,14 @@ export declare const zh: {
250
258
  'manual.5.auto.body': string;
251
259
  'manual.5.manual.label': string;
252
260
  'manual.5.manual.body': string;
261
+ 'manual.5.twoMachines.label': string;
262
+ 'manual.5.twoMachines.body': string;
253
263
  'manual.5.delete.label': string;
254
264
  'manual.5.delete.body': string;
255
- 'manual.5.conflict.label': string;
256
- 'manual.5.conflict.body': string;
265
+ 'manual.5.merge.label': string;
266
+ 'manual.5.merge.body': string;
267
+ 'manual.5.cloud.label': string;
268
+ 'manual.5.cloud.body': string;
257
269
  'manual.6.title': string;
258
270
  'manual.6.query.label': string;
259
271
  'manual.6.query.body': string;
@@ -386,6 +398,9 @@ export declare const MESSAGES: {
386
398
  'settings.region': string;
387
399
  'settings.bucketAddress': string;
388
400
  'settings.bucketDir': string;
401
+ 'settings.adoptForeign': string;
402
+ 'settings.adoptForeignHint': string;
403
+ 'settings.directoryPlaceholder': string;
389
404
  'settings.dirS3.lead': string;
390
405
  'settings.dirS3.mid': string;
391
406
  'settings.dirS3.tail': string;
@@ -401,6 +416,7 @@ export declare const MESSAGES: {
401
416
  'settings.save': string;
402
417
  'settings.probe': string;
403
418
  'settings.details': string;
419
+ 'settings.syncRootNow': string;
404
420
  'settings.noServiceBody': string;
405
421
  'settings.bucketHint': string;
406
422
  'notice.listFailed': string;
@@ -432,16 +448,20 @@ export declare const MESSAGES: {
432
448
  'sync.pushUnconfigured': string;
433
449
  'sync.pushFailed': string;
434
450
  'sync.pushUnknown': string;
435
- 'sync.pushUpToDate': string;
436
- 'sync.pushDone': string;
437
451
  'sync.pushPartial': string;
438
452
  'sync.pullNoAddress': string;
439
453
  'sync.pullFailed': string;
440
- 'sync.pullNothingNew': string;
441
- 'sync.pullMerged': string;
442
- 'sync.pullAttachments': string;
443
- 'sync.pullDone': string;
444
- 'sync.pullFailedCount': string;
454
+ 'sync.detailPushedIdle': string;
455
+ 'sync.detailPushed': string;
456
+ 'sync.detailPulled': string;
457
+ 'sync.detailAdded': string;
458
+ 'sync.detailSkip': string;
459
+ 'sync.pullForeignSync': string;
460
+ 'sync.detailForeign': string;
461
+ 'sync.shortPushed': string;
462
+ 'sync.shortPulled': string;
463
+ 'sync.shortIdle': string;
464
+ 'sync.pullFailures': string;
445
465
  'sync.thisPage': string;
446
466
  'settings.status': string;
447
467
  'settings.statusOn': string;
@@ -507,10 +527,14 @@ export declare const MESSAGES: {
507
527
  'manual.5.auto.body': string;
508
528
  'manual.5.manual.label': string;
509
529
  'manual.5.manual.body': string;
530
+ 'manual.5.twoMachines.label': string;
531
+ 'manual.5.twoMachines.body': string;
510
532
  'manual.5.delete.label': string;
511
533
  'manual.5.delete.body': string;
512
- 'manual.5.conflict.label': string;
513
- 'manual.5.conflict.body': string;
534
+ 'manual.5.merge.label': string;
535
+ 'manual.5.merge.body': string;
536
+ 'manual.5.cloud.label': string;
537
+ 'manual.5.cloud.body': string;
514
538
  'manual.6.title': string;
515
539
  'manual.6.query.label': string;
516
540
  'manual.6.query.body': string;
@@ -42,6 +42,14 @@ export interface SyncTree {
42
42
  export interface MergeOutcome {
43
43
  /** Records that were new here, or overwritten because the remote was newer. */
44
44
  merged: number;
45
+ /**
46
+ * How many of {@link merged} were ids this vault did not have at all.
47
+ *
48
+ * "19 records came over" and "the vault grew by 6" are both true at once, and
49
+ * the reader who just watched a number expects them to match (asked
50
+ * 2026-09-21, after merging an abandoned tree whose copies mostly overlapped).
51
+ */
52
+ added: number;
45
53
  /** Records the remote had and this machine already had, newer or equal. */
46
54
  kept: number;
47
55
  /** Attachment objects pulled down (bytes plus their row). */
@@ -76,5 +84,13 @@ export declare function mergeOnce(vault: Vault, tree: SyncTree, prefix: string,
76
84
  * @param attachments - where attachment bytes go.
77
85
  * @returns the counts and the lines worth showing; never throws.
78
86
  */
79
- export declare function mergeRemote(ctx: Context, vault: Vault, attachments: AttachmentStore): Promise<MergeOutcome>;
87
+ export declare function mergeRemote(ctx: Context, vault: Vault, attachments: AttachmentStore,
88
+ /**
89
+ * Further sync trees to merge, on top of this machine's own.
90
+ *
91
+ * The pull hands over the trees it found elsewhere in the bucket (a machine
92
+ * that used to sync under another directory leaves one behind). Each is read
93
+ * exactly like our own: per record, `id` + `updatedAt` decides.
94
+ */
95
+ extraRoots?: readonly string[]): Promise<MergeOutcome>;
80
96
  export {};
@@ -43,7 +43,18 @@ export interface RemoteSource {
43
43
  * @param attachments - the store that owns pulled bytes.
44
44
  * @returns what happened.
45
45
  */
46
- export declare function ingestFrom(vault: Vault, source: RemoteSource, attachments: AttachmentStore): Promise<PullResult>;
46
+ export declare function ingestFrom(vault: Vault, source: RemoteSource, attachments: AttachmentStore,
47
+ /** This machine's own sync root; anything else under `sync/` is a stranger. */
48
+ syncRoot?: string,
49
+ /**
50
+ * Sync roots found **outside** the listed scope, from a protocol-specific
51
+ * probe. A listing scoped to our directory cannot see them, and without them
52
+ * "the other machine's records never arrived" has no explanation to show.
53
+ */
54
+ elsewhere?: {
55
+ roots: readonly string[];
56
+ records?: number;
57
+ }): Promise<PullResult>;
47
58
  /** What the user configures for WebDAV. */
48
59
  export interface WebdavConfig {
49
60
  baseUrl: string;
@@ -54,7 +65,20 @@ export interface WebdavConfig {
54
65
  export declare function pullRemote(vault: Vault, config: WebdavConfig, deps: WebdavDeps & {
55
66
  attachments: AttachmentStore;
56
67
  }): Promise<PullResult>;
57
- /** Pull over S3. */
58
- export declare function pullS3(vault: Vault, config: S3Config, prefix: string, deps: S3Deps & {
68
+ /**
69
+ * Pull over S3.
70
+ *
71
+ * @param directory - the configured directory, exactly as the settings hold it.
72
+ * `/`, an empty string and "never set" all mean the default; the listing is
73
+ * scoped to it and the vault's own tree is `<directory>/sync`.
74
+ *
75
+ * This used to be handed the sync root as the *listing* prefix, and to treat
76
+ * that same string as "ours": with S3 every object the vault had uploaded
77
+ * therefore answered to a prefix that was not ours, so the panel reported
78
+ * 「自己的同步对象 0 项」 and warned about another machine's directory — which was
79
+ * this machine's own (measured 2026-09-21, a bucket holding both `sync/…` from
80
+ * the older build and `inbox/sync/…` from this one).
81
+ */
82
+ export declare function pullS3(vault: Vault, config: S3Config, directory: string | undefined, deps: S3Deps & {
59
83
  attachments: AttachmentStore;
60
84
  }): Promise<PullResult>;
@@ -173,7 +173,31 @@ export declare function parseListing(xml: string): RemoteObject[];
173
173
  * @param deps - credentials, fetch, clock.
174
174
  * @returns the objects found.
175
175
  */
176
- export declare function listPrefix(config: S3Config, prefix: string, deps: S3Deps): Promise<RemoteObject[]>;
176
+ export declare function listPrefix(config: S3Config, prefix: string, deps: S3Deps,
177
+ /** Extra query parameters, e.g. `{ delimiter: '/', 'max-keys': '1' }`. */
178
+ extra?: Record<string, string>): Promise<RemoteObject[]>;
179
+ /**
180
+ * The top-level folders a bucket has.
181
+ *
182
+ * `delimiter=/` collapses everything below the first slash into
183
+ * `<CommonPrefixes>`, which is the cheap way to ask "what else lives up here?" —
184
+ * one request, however many objects the bucket holds. It is what makes "another
185
+ * machine synced into a different directory" visible: that machine's records are
186
+ * not under our directory, so a listing scoped to ours can never see them, and
187
+ * the silence looks exactly like "nothing new" (asked 2026-09-21).
188
+ *
189
+ * @param config - endpoint, bucket, region.
190
+ * @param deps - credentials, fetch, clock.
191
+ * @returns folder names without the trailing slash, e.g. `['inbox', 'sync']`.
192
+ */
193
+ export declare function listTopLevel(config: S3Config, deps: S3Deps): Promise<string[]>;
194
+ /**
195
+ * Read the folder names out of a `delimiter=/` listing.
196
+ *
197
+ * @param xml - the response body.
198
+ * @returns each prefix without its trailing slash.
199
+ */
200
+ export declare function parsePrefixes(xml: string): string[];
177
201
  /** Fetch one object's bytes, with its content type when the server sends one. */
178
202
  export declare function readObject(config: S3Config, key: string, deps: S3Deps): Promise<{
179
203
  bytes: Uint8Array;