@chance722/dsh-inbox 0.2.3 → 0.2.5

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.3" : "0.0.0-dev";
6
+ var VERSION = true ? "0.2.5" : "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 });
@@ -978,6 +1000,7 @@ var DEFAULT_SETTINGS = {
978
1000
  protocol: "webdav",
979
1001
  baseUrl: "",
980
1002
  directory: "/inbox",
1003
+ adoptForeignRoots: false,
981
1004
  username: "",
982
1005
  endpoint: "",
983
1006
  bucket: "",
@@ -991,6 +1014,7 @@ var WebdavSettingsSchema = z.object({
991
1014
  protocol: z.union(["webdav", "s3"]).default("webdav"),
992
1015
  baseUrl: z.string().default(""),
993
1016
  directory: z.string().default("/inbox"),
1017
+ adoptForeignRoots: z.boolean().default(false),
994
1018
  username: z.string().default(""),
995
1019
  endpoint: z.string().default(""),
996
1020
  bucket: z.string().default(""),
@@ -1062,6 +1086,7 @@ async function saveWebdav(ctx, vault, base, patch) {
1062
1086
  const directory = patch.directory.trim();
1063
1087
  config.directory = directory.startsWith("/") ? directory : `/${directory}`;
1064
1088
  }
1089
+ if (patch.adoptForeignRoots !== void 0) config.adoptForeignRoots = patch.adoptForeignRoots;
1065
1090
  if (patch.username !== void 0) config.username = patch.username.trim();
1066
1091
  if (patch.endpoint !== void 0) config.endpoint = normalizeUrl(patch.endpoint);
1067
1092
  if (patch.bucket !== void 0) config.bucket = patch.bucket.trim();
@@ -1137,7 +1162,7 @@ function isNewer(entry, lastPullAt) {
1137
1162
  if (Number.isNaN(seen) || Number.isNaN(remote)) return true;
1138
1163
  return remote > seen;
1139
1164
  }
1140
- async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(void 0)) {
1165
+ async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(void 0), elsewhere = { roots: [] }) {
1141
1166
  const lastPullAt = vault.global.sync.lastPullAt;
1142
1167
  let entries;
1143
1168
  try {
@@ -1153,7 +1178,10 @@ async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(vo
1153
1178
  let skippedSync = 0;
1154
1179
  let skippedOlder = 0;
1155
1180
  let skippedForeign = 0;
1156
- const foreignSyncRoots = /* @__PURE__ */ new Set();
1181
+ let remoteRecords = 0;
1182
+ let remoteAttachments = 0;
1183
+ const foreignSyncRoots = new Set(elsewhere.roots);
1184
+ let foreignRecords = elsewhere.records ?? 0;
1157
1185
  let newestSuccess;
1158
1186
  const failures = [];
1159
1187
  for (const entry of entries) {
@@ -1162,11 +1190,20 @@ async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(vo
1162
1190
  skipped += 1;
1163
1191
  skippedForeign += 1;
1164
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
+ }
1165
1197
  continue;
1166
1198
  }
1167
1199
  if (prefix !== void 0) {
1168
1200
  skipped += 1;
1169
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;
1170
1207
  continue;
1171
1208
  }
1172
1209
  if (!isNewer(entry, lastPullAt)) {
@@ -1232,8 +1269,11 @@ async function ingestFrom(vault, source, attachments, syncRoot3 = syncRootFor(vo
1232
1269
  skippedSync,
1233
1270
  skippedOlder,
1234
1271
  skippedForeign,
1272
+ remoteRecords,
1273
+ remoteAttachments,
1235
1274
  syncRoot: syncRoot3,
1236
1275
  ...foreignSyncRoots.size === 0 ? {} : { foreignSyncRoots: [...foreignSyncRoots] },
1276
+ ...foreignRecords === 0 ? {} : { foreignRecords },
1237
1277
  listed,
1238
1278
  lastPullAt: cursor,
1239
1279
  ...failures.length === 0 ? {} : { reason: failures.slice(0, 3).join("\uFF1B") }
@@ -1267,7 +1307,7 @@ async function pullRemote(vault, config, deps) {
1267
1307
  syncRootFor(config.directory)
1268
1308
  );
1269
1309
  }
1270
- async function pullS3(vault, config, prefix, deps) {
1310
+ async function pullS3(vault, config, directory, deps) {
1271
1311
  if (config.endpoint.trim().length === 0 || config.bucket.trim().length === 0) {
1272
1312
  return {
1273
1313
  status: "unconfigured",
@@ -1278,20 +1318,44 @@ async function pullS3(vault, config, prefix, deps) {
1278
1318
  listed: 0
1279
1319
  };
1280
1320
  }
1321
+ const scope = syncDirectory(directory);
1281
1322
  return ingestFrom(
1282
1323
  vault,
1283
1324
  {
1284
- list: async () => (await listPrefix(config, prefix, deps)).map((object) => ({
1325
+ list: async () => (await listPrefix(config, `${scope}/`, deps)).map((object) => ({
1285
1326
  path: object.key,
1286
1327
  ...object.lastModified === void 0 ? {} : { lastModified: object.lastModified }
1287
1328
  })),
1288
1329
  read: async (entry) => readObject(config, entry.path, deps)
1289
1330
  },
1290
1331
  deps.attachments,
1291
- // S3 is handed the prefix by its caller that prefix *is* the sync root.
1292
- prefix
1332
+ // The vault's own tree, resolved by the same rule the writer uses.
1333
+ syncRootFor(directory),
1334
+ await s3RootsElsewhere(config, deps, scope)
1293
1335
  );
1294
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
+ }
1295
1359
 
1296
1360
  // src/host/remote/merge.ts
1297
1361
  import { admitEncodedFile as admitEncodedFile2, admitEncodedImages as admitEncodedImages2 } from "@deepseek-ai/dsh-attachment";
@@ -1339,13 +1403,15 @@ function wins(remote, local) {
1339
1403
  async function mergeOnce(vault, tree, prefix, admit) {
1340
1404
  const failures = [];
1341
1405
  let merged = 0;
1406
+ let added = 0;
1407
+ let deletions = 0;
1342
1408
  let kept = 0;
1343
1409
  let attachments = 0;
1344
1410
  let objects;
1345
1411
  try {
1346
1412
  objects = await tree.list(`${prefix}/items/`);
1347
1413
  } catch (error) {
1348
- return { merged: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1414
+ return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1349
1415
  }
1350
1416
  const wanted = /* @__PURE__ */ new Map();
1351
1417
  const bytesAt = /* @__PURE__ */ new Map();
@@ -1375,6 +1441,8 @@ async function mergeOnce(vault, tree, prefix, admit) {
1375
1441
  }
1376
1442
  await vault.import(remote);
1377
1443
  merged += 1;
1444
+ if (local === void 0) added += 1;
1445
+ if (remote.deletedAt !== void 0) deletions += 1;
1378
1446
  for (const attachmentId of remote.attachmentIds) {
1379
1447
  if (vault.getAttachment(attachmentId) === void 0) {
1380
1448
  wanted.set(attachmentId, remote.id);
@@ -1409,7 +1477,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1409
1477
  failures.push(`\u9644\u4EF6 ${attachmentId}\uFF1A${reasonOf(error)}`);
1410
1478
  }
1411
1479
  }
1412
- return { merged, kept, attachments, failures };
1480
+ return { merged, added, deletions, kept, attachments, failures };
1413
1481
  }
1414
1482
  function extensionOfName(path) {
1415
1483
  return /\.([A-Za-z0-9]{1,8})$/.exec(nameOf2(path))?.[1]?.toLowerCase() ?? "bin";
@@ -1417,15 +1485,27 @@ function extensionOfName(path) {
1417
1485
  function reasonOf(error) {
1418
1486
  return error instanceof Error ? error.message : String(error);
1419
1487
  }
1420
- async function mergeRemote(ctx, vault, attachments) {
1488
+ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1421
1489
  const settings = readSettings(ctx);
1422
1490
  const prefix = syncRoot(settings);
1423
1491
  try {
1424
1492
  const tree = settings.protocol === "s3" ? await s3Tree(ctx, settings) : await webdavTree(ctx, settings);
1425
- if (tree === void 0) return { merged: 0, kept: 0, attachments: 0, failures: [] };
1426
- return await mergeOnce(vault, tree, prefix, admitWith(attachments));
1493
+ if (tree === void 0) return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [] };
1494
+ const admit = admitWith(attachments);
1495
+ const outcome = await mergeOnce(vault, tree, prefix, admit);
1496
+ for (const root of extraRoots) {
1497
+ if (root === prefix) continue;
1498
+ const extra = await mergeOnce(vault, tree, root, admit);
1499
+ outcome.merged += extra.merged;
1500
+ outcome.added += extra.added;
1501
+ outcome.deletions += extra.deletions;
1502
+ outcome.kept += extra.kept;
1503
+ outcome.attachments += extra.attachments;
1504
+ outcome.failures.push(...extra.failures);
1505
+ }
1506
+ return outcome;
1427
1507
  } catch (error) {
1428
- return { merged: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1508
+ return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1429
1509
  }
1430
1510
  }
1431
1511
  function admitWith(store) {
@@ -1524,11 +1604,16 @@ async function runPull(ctx, vault, attachments) {
1524
1604
  }
1525
1605
  async function withMerge(ctx, vault, attachments, pulled) {
1526
1606
  if (pulled.status !== "ok" || attachments === void 0) return pulled;
1527
- const outcome = await mergeRemote(ctx, vault, attachments);
1607
+ const settings = readSettings(ctx);
1608
+ const extraRoots = settings.adoptForeignRoots ? pulled.foreignSyncRoots ?? [] : [];
1609
+ const outcome = await mergeRemote(ctx, vault, attachments, extraRoots);
1528
1610
  const troubles = [...pulled.failed > 0 && pulled.reason !== void 0 ? [pulled.reason] : [], ...outcome.failures];
1529
1611
  return {
1530
1612
  ...pulled,
1531
1613
  merged: outcome.merged,
1614
+ added: outcome.added,
1615
+ deletions: outcome.deletions,
1616
+ kept: outcome.kept,
1532
1617
  attachments: outcome.attachments,
1533
1618
  failed: pulled.failed + outcome.failures.length,
1534
1619
  ...troubles.length === 0 ? {} : { reason: troubles.slice(0, 3).join("\uFF1B") }
@@ -1577,7 +1662,10 @@ async function pullDropFolder(ctx, vault, attachments) {
1577
1662
  signatureVersion: settings.signatureVersion,
1578
1663
  userAgent: activeUserAgent(settings)
1579
1664
  },
1580
- settings.directory.replace(/^\//, ""),
1665
+ // The configured directory, not a pre-trimmed string: `/`, `''` and
1666
+ // "unset" all mean the default, and the ingest resolves the vault's own
1667
+ // root from the same rule the writer uses.
1668
+ settings.directory,
1581
1669
  {
1582
1670
  fetch: s3Fetch,
1583
1671
  accessKeyId: settings.accessKeyId,
@@ -3102,6 +3190,9 @@ async function handleWebdav(ctx, vault, payload) {
3102
3190
  if (typeof request.protocol === "string") patch.protocol = request.protocol;
3103
3191
  if (typeof request.baseUrl === "string") patch.baseUrl = request.baseUrl;
3104
3192
  if (typeof request.directory === "string") patch.directory = request.directory;
3193
+ if (typeof request.adoptForeignRoots === "boolean") {
3194
+ patch.adoptForeignRoots = request.adoptForeignRoots;
3195
+ }
3105
3196
  if (typeof request.username === "string") patch.username = request.username;
3106
3197
  if (typeof request.password === "string") patch.password = request.password;
3107
3198
  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;
@@ -176,17 +179,19 @@ export declare const zh: {
176
179
  'sync.pushUnconfigured': string;
177
180
  'sync.pushFailed': string;
178
181
  'sync.pushUnknown': string;
179
- 'sync.pushUpToDate': string;
180
- 'sync.pushDone': string;
181
182
  'sync.pushPartial': string;
182
183
  'sync.pullNoAddress': string;
183
184
  'sync.pullFailed': string;
184
- 'sync.pullNothingNew': string;
185
- 'sync.pullMerged': string;
186
- 'sync.pullAttachments': string;
187
- 'sync.pullDone': string;
188
- 'sync.pullSkipWhy': string;
185
+ 'sync.detailPushedIdle': string;
186
+ 'sync.detailPushed': string;
187
+ 'sync.detailPulled': string;
188
+ 'sync.detailAdded': string;
189
+ 'sync.detailDeleted': string;
189
190
  'sync.pullForeignSync': string;
191
+ 'sync.detailForeign': string;
192
+ 'sync.shortPushed': string;
193
+ 'sync.shortPulled': string;
194
+ 'sync.shortIdle': string;
190
195
  'sync.pullFailures': string;
191
196
  'sync.thisPage': string;
192
197
  'settings.status': string;
@@ -253,10 +258,14 @@ export declare const zh: {
253
258
  'manual.5.auto.body': string;
254
259
  'manual.5.manual.label': string;
255
260
  'manual.5.manual.body': string;
261
+ 'manual.5.twoMachines.label': string;
262
+ 'manual.5.twoMachines.body': string;
256
263
  'manual.5.delete.label': string;
257
264
  'manual.5.delete.body': string;
258
- 'manual.5.conflict.label': string;
259
- '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;
260
269
  'manual.6.title': string;
261
270
  'manual.6.query.label': string;
262
271
  'manual.6.query.body': string;
@@ -389,6 +398,9 @@ export declare const MESSAGES: {
389
398
  'settings.region': string;
390
399
  'settings.bucketAddress': string;
391
400
  'settings.bucketDir': string;
401
+ 'settings.adoptForeign': string;
402
+ 'settings.adoptForeignHint': string;
403
+ 'settings.directoryPlaceholder': string;
392
404
  'settings.dirS3.lead': string;
393
405
  'settings.dirS3.mid': string;
394
406
  'settings.dirS3.tail': string;
@@ -436,17 +448,19 @@ export declare const MESSAGES: {
436
448
  'sync.pushUnconfigured': string;
437
449
  'sync.pushFailed': string;
438
450
  'sync.pushUnknown': string;
439
- 'sync.pushUpToDate': string;
440
- 'sync.pushDone': string;
441
451
  'sync.pushPartial': string;
442
452
  'sync.pullNoAddress': string;
443
453
  'sync.pullFailed': string;
444
- 'sync.pullNothingNew': string;
445
- 'sync.pullMerged': string;
446
- 'sync.pullAttachments': string;
447
- 'sync.pullDone': string;
448
- 'sync.pullSkipWhy': string;
454
+ 'sync.detailPushedIdle': string;
455
+ 'sync.detailPushed': string;
456
+ 'sync.detailPulled': string;
457
+ 'sync.detailAdded': string;
458
+ 'sync.detailDeleted': string;
449
459
  'sync.pullForeignSync': string;
460
+ 'sync.detailForeign': string;
461
+ 'sync.shortPushed': string;
462
+ 'sync.shortPulled': string;
463
+ 'sync.shortIdle': string;
450
464
  'sync.pullFailures': string;
451
465
  'sync.thisPage': string;
452
466
  'settings.status': string;
@@ -513,10 +527,14 @@ export declare const MESSAGES: {
513
527
  'manual.5.auto.body': string;
514
528
  'manual.5.manual.label': string;
515
529
  'manual.5.manual.body': string;
530
+ 'manual.5.twoMachines.label': string;
531
+ 'manual.5.twoMachines.body': string;
516
532
  'manual.5.delete.label': string;
517
533
  'manual.5.delete.body': string;
518
- 'manual.5.conflict.label': string;
519
- '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;
520
538
  'manual.6.title': string;
521
539
  'manual.6.query.label': string;
522
540
  'manual.6.query.body': string;
@@ -42,6 +42,23 @@ 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} arrived as deletions.
47
+ *
48
+ * A deleted record travels as an ordinary record with deletedAt set, so a
49
+ * merge that brings deletions looks exactly like one that brings updates until
50
+ * somebody opens the recycle bin (measured 2026-09-21: 19 merged — 6 new
51
+ * records and 13 tombstones, and the bin went from empty to thirteen).
52
+ */
53
+ deletions: number;
54
+ /**
55
+ * How many of {@link merged} were ids this vault did not have at all.
56
+ *
57
+ * "19 records came over" and "the vault grew by 6" are both true at once, and
58
+ * the reader who just watched a number expects them to match (asked
59
+ * 2026-09-21, after merging an abandoned tree whose copies mostly overlapped).
60
+ */
61
+ added: number;
45
62
  /** Records the remote had and this machine already had, newer or equal. */
46
63
  kept: number;
47
64
  /** Attachment objects pulled down (bytes plus their row). */
@@ -76,5 +93,13 @@ export declare function mergeOnce(vault: Vault, tree: SyncTree, prefix: string,
76
93
  * @param attachments - where attachment bytes go.
77
94
  * @returns the counts and the lines worth showing; never throws.
78
95
  */
79
- export declare function mergeRemote(ctx: Context, vault: Vault, attachments: AttachmentStore): Promise<MergeOutcome>;
96
+ export declare function mergeRemote(ctx: Context, vault: Vault, attachments: AttachmentStore,
97
+ /**
98
+ * Further sync trees to merge, on top of this machine's own.
99
+ *
100
+ * The pull hands over the trees it found elsewhere in the bucket (a machine
101
+ * that used to sync under another directory leaves one behind). Each is read
102
+ * exactly like our own: per record, `id` + `updatedAt` decides.
103
+ */
104
+ extraRoots?: readonly string[]): Promise<MergeOutcome>;
80
105
  export {};
@@ -45,7 +45,16 @@ export interface RemoteSource {
45
45
  */
46
46
  export declare function ingestFrom(vault: Vault, source: RemoteSource, attachments: AttachmentStore,
47
47
  /** This machine's own sync root; anything else under `sync/` is a stranger. */
48
- syncRoot?: string): Promise<PullResult>;
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>;
49
58
  /** What the user configures for WebDAV. */
50
59
  export interface WebdavConfig {
51
60
  baseUrl: string;
@@ -56,7 +65,20 @@ export interface WebdavConfig {
56
65
  export declare function pullRemote(vault: Vault, config: WebdavConfig, deps: WebdavDeps & {
57
66
  attachments: AttachmentStore;
58
67
  }): Promise<PullResult>;
59
- /** Pull over S3. */
60
- 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 & {
61
83
  attachments: AttachmentStore;
62
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;
@@ -28,6 +28,7 @@ export declare const WebdavSettingsSchema: z<Schemastery.ObjectS<{
28
28
  protocol: z<"webdav" | "s3", "webdav" | "s3">;
29
29
  baseUrl: z<string, string>;
30
30
  directory: z<string, string>;
31
+ adoptForeignRoots: z<boolean, boolean>;
31
32
  username: z<string, string>;
32
33
  endpoint: z<string, string>;
33
34
  bucket: z<string, string>;
@@ -40,6 +41,7 @@ export declare const WebdavSettingsSchema: z<Schemastery.ObjectS<{
40
41
  protocol: z<"webdav" | "s3", "webdav" | "s3">;
41
42
  baseUrl: z<string, string>;
42
43
  directory: z<string, string>;
44
+ adoptForeignRoots: z<boolean, boolean>;
43
45
  username: z<string, string>;
44
46
  endpoint: z<string, string>;
45
47
  bucket: z<string, string>;
@@ -75,6 +77,8 @@ export interface WebdavPatch {
75
77
  protocol?: string;
76
78
  baseUrl?: string;
77
79
  directory?: string;
80
+ /** Merge other sync trees in the same bucket, not just this directory's. */
81
+ adoptForeignRoots?: boolean;
78
82
  username?: string;
79
83
  /** Empty string clears the stored password; undefined leaves it. */
80
84
  password?: string;
@@ -48,6 +48,8 @@ export interface WebdavRequest {
48
48
  protocol?: RemoteProtocol;
49
49
  baseUrl?: string;
50
50
  directory?: string;
51
+ /** Merge other sync trees in the same bucket, not just this directory's. */
52
+ adoptForeignRoots?: boolean;
51
53
  username?: string;
52
54
  /** Empty string clears the stored password; absent leaves it alone. */
53
55
  password?: string;
@@ -82,6 +84,18 @@ export interface WebdavSettings {
82
84
  baseUrl: string;
83
85
  /** Folder under the base URL; the convention every device drops into. */
84
86
  directory: string;
87
+ /**
88
+ * Also merge `…/sync` trees found **outside** the configured directory.
89
+ *
90
+ * Off by default, because the directory is what tells two vaults apart and a
91
+ * bucket can be shared. On, this is what a person means by "it is all my
92
+ * cloud drive": a machine that used to sync somewhere else leaves its records
93
+ * behind, and they come back (asked 2026-09-21, 19 records). Merging settles
94
+ * per record by `id` + `updatedAt`, so an older tree cannot overwrite a newer
95
+ * copy — but it *can* bring back a record that was purged here, because a
96
+ * purge leaves nothing local to outrank it.
97
+ */
98
+ adoptForeignRoots: boolean;
85
99
  username: string;
86
100
  /** S3: endpoint host, e.g. `https://s3.cstcloud.cn`. */
87
101
  endpoint: string;
@@ -159,8 +173,21 @@ export interface PullResult {
159
173
  * than the local copy). Only the merge half of a pull can produce these.
160
174
  */
161
175
  merged?: number;
176
+ /** How many of {@link merged} were ids this vault did not have at all. */
177
+ added?: number;
178
+ /** How many of {@link merged} were deletions (they land in the recycle bin). */
179
+ deletions?: number;
162
180
  /** Attachment objects the merge had to fetch and admit locally. */
163
181
  attachments?: number;
182
+ /**
183
+ * Records the cloud holds that this vault **already had** (same id, not older).
184
+ *
185
+ * This is the answer to "why is the cloud's copy not coming over?": it is the
186
+ * same record, and the merge settles per record by `id` + `updatedAt` rather
187
+ * than by which machine uploaded it — a record this machine pushed comes back
188
+ * with the timestamp it left with, so it is kept, not re-filed.
189
+ */
190
+ kept?: number;
164
191
  failed: number;
165
192
  /** Files the server listed but we skipped as already-seen. */
166
193
  skipped: number;
@@ -180,8 +207,17 @@ export interface PullResult {
180
207
  skippedForeign?: number;
181
208
  /** Those other prefixes, e.g. `['inbox/sync']`; the panel warns about them. */
182
209
  foreignSyncRoots?: string[];
210
+ /** How many `items/*.json` records those other prefixes hold together. */
211
+ foreignRecords?: number;
183
212
  /** This machine's own sync root, so the warning can name both sides. */
184
213
  syncRoot?: string;
214
+ /**
215
+ * How many **records** the cloud holds, as opposed to how many objects that
216
+ * takes: `items/<id>.json` files under this machine's own sync root.
217
+ */
218
+ remoteRecords?: number;
219
+ /** How many attachments the cloud holds (`attachments/<id>.<ext>`, no descriptors). */
220
+ remoteAttachments?: number;
185
221
  /** How many entries the remote listed at all: distinguishes "empty folder"
186
222
  * from "everything already ingested". */
187
223
  listed: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chance722/dsh-inbox",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "description": "Personal paste inbox for DeepSeek Harness (dsh): capture, classify, browse and retrieve.",
6
6
  "keywords": [
@@ -64,7 +64,10 @@
64
64
  "build": "node scripts/build.mjs && tsc -p tsconfig.build.json",
65
65
  "typecheck": "tsc --noEmit",
66
66
  "test": "vitest run",
67
- "prepublishOnly": "npm run build"
67
+ "prepublishOnly": "npm run build",
68
+ "dev:status": "node scripts/dev.mjs status",
69
+ "dev:local": "node scripts/dev.mjs local",
70
+ "dev:npm": "node scripts/dev.mjs npm"
68
71
  },
69
72
  "dependencies": {
70
73
  "lucide-react": "^1.47.0",