dsh-webdav-sync 1.0.1 → 1.0.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.
package/lib/engine.js CHANGED
@@ -17,10 +17,27 @@ import { readFile } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
18
  import { BUNDLE_APP, BUNDLE_FORMAT, BUNDLE_VERSION, PARTS, REMOTE_FILE, } from './wire.js';
19
19
  import { CorruptError, NotBackupError, PassphraseError, hashJson, open, seal, sum } from './crypto.js';
20
- import { Dav, LoginError, RateLimitedError } from './dav.js';
20
+ import { Dav, LoginError, RateLimitedError, parseHeaders } from './dav.js';
21
+ import { dirsDiffer, dirsPartOf, readDirs, writeDirs } from './dirs.js';
21
22
  import { applyManifest, applyPatch, collect, isMissing, keepAside, message, missingPlugins, readManifest, readPatch, writeIfChanged, } from './store.js';
22
23
  import { cached, ensureStateDir, keptDir, loadConfig, loadState, normaliseUrl, releasePassword, remember, sameAccount, saveConfig, saveState, stateKey, syncsPart, usable, } from './state.js';
23
24
  import { resolvePaths } from './paths.js';
25
+ /**
26
+ * This computer's whole setup, as parts.
27
+ *
28
+ * The four profile parts are read once; the extra directories have to be walked,
29
+ * so they arrive separately and are put back together here. `carried` is the
30
+ * server's `dirs` part, and it is used for the directories this computer could not
31
+ * read — see {@link dirsPartOf} for why carrying those beats reading them as
32
+ * empty ones.
33
+ */
34
+ function partsOf(snapshot, carried) {
35
+ const dirs = dirsPartOf(snapshot.dirs, carried);
36
+ // No directories named and none carried: the part is left out entirely rather
37
+ // than sent as an empty one, so a computer that syncs none of them can never be
38
+ // the one that empties the part for the computers that do.
39
+ return dirs === undefined ? { ...snapshot.parts } : { ...snapshot.parts, dirs };
40
+ }
24
41
  /**
25
42
  * One operation at a time, per process.
26
43
  *
@@ -58,8 +75,15 @@ export async function sync(engine, options = {}) {
58
75
  await ensureStateDir(paths);
59
76
  const state = await freshState(paths, config);
60
77
  const outcome = await syncOnce(engine, state, options.force === true);
61
- state.last = new Date().toISOString();
78
+ const at = new Date().toISOString();
79
+ state.last = at;
62
80
  state.error = '';
81
+ // `here` and `there` name the parts that moved, and `pushed` says the sealed
82
+ // file went up: any of the three means this attempt moved bytes. An attempt
83
+ // that only looked leaves `moved` where it was, which is what tells a check
84
+ // apart from a sync on the card. See docs/decisions/0039-the-card-says-check-and-sync-apart.md.
85
+ if (outcome.pushed || outcome.here.length > 0 || outcome.there.length > 0)
86
+ state.moved = at;
63
87
  await saveState(paths, state);
64
88
  return outcome;
65
89
  });
@@ -114,7 +138,7 @@ async function recordFailure(engine, cause) {
114
138
  }
115
139
  async function syncOnce(engine, state, force) {
116
140
  const { paths, config } = engine;
117
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
141
+ const dav = davOf(config);
118
142
  const read = await dav.get(state.server);
119
143
  // A write that went out without the server checking its version may have
120
144
  // overlapped another computer's, and which of the two landed cannot be told
@@ -138,11 +162,17 @@ async function syncOnce(engine, state, force) {
138
162
  }
139
163
  state.blind = undefined;
140
164
  }
141
- const snapshot = await take(paths);
142
- const local = hashesOf(snapshot.parts);
165
+ const snapshot = await take(paths, config);
143
166
  let remote = await openRemote(engine, state, read);
144
167
  const first = state.local === undefined;
145
- if (remote.notModified && sameHashes(local, state.local) && !force) {
168
+ // A directory could not be read this run: its part stands in for the server's
169
+ // copy of it, and the server's copy cannot be known from a `notModified` answer
170
+ // — so the comparison below is made without it and comes out false, which takes
171
+ // this sync the long way round and reads the file. That is the right price for
172
+ // not confusing "could not measure" with "measured, and it is empty".
173
+ if (remote.notModified
174
+ && sameHashes(hashesOf(partsOf(snapshot, remote.part('dirs'))), state.local)
175
+ && !force) {
146
176
  // Neither side moved: the last sync is still the state of the world.
147
177
  return { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
148
178
  }
@@ -152,12 +182,15 @@ async function syncOnce(engine, state, force) {
152
182
  // needed to merge against and it is read again, this time in full.
153
183
  remote = await openRemote(engine, state, await dav.get());
154
184
  }
185
+ const carried = remote.part('dirs');
186
+ const localParts = partsOf(snapshot, carried);
187
+ const local = hashesOf(localParts);
155
188
  // The server holds no file of ours. This computer's setup becomes the first
156
189
  // version, which is what joining an empty folder means.
157
190
  if (remote.empty) {
158
191
  return await push(engine, state, snapshot, {
159
- merged: { ...snapshot.parts },
160
- applied: { ...snapshot.parts },
192
+ merged: { ...localParts },
193
+ applied: { ...localParts },
161
194
  remote: remote.hashes,
162
195
  version: remote.version,
163
196
  here: [],
@@ -198,8 +231,8 @@ async function syncOnce(engine, state, force) {
198
231
  const ours = localChangedAt(snapshot, part);
199
232
  if (!Number.isFinite(serverAt) || ours >= serverAt) {
200
233
  if (local[part] !== undefined) {
201
- merged[part] = snapshot.parts[part];
202
- applied[part] = snapshot.parts[part];
234
+ merged[part] = localParts[part];
235
+ applied[part] = localParts[part];
203
236
  there.push(part);
204
237
  }
205
238
  }
@@ -226,6 +259,15 @@ async function syncOnce(engine, state, force) {
226
259
  applied[part] = remotePart();
227
260
  here.push(part);
228
261
  }
262
+ else if (part === 'dirs' && local[part] !== undefined) {
263
+ // The one removal this computer does not take. Its own directories are still
264
+ // named here, and the part does nothing but say which paths travel: taking it
265
+ // away would leave them unsynced for no gain, so the copy here is what goes
266
+ // back up.
267
+ merged[part] = localParts[part];
268
+ applied[part] = localParts[part];
269
+ there.push(part);
270
+ }
229
271
  else if (removable) {
230
272
  applied[part] = null;
231
273
  here.push(part);
@@ -235,26 +277,39 @@ async function syncOnce(engine, state, force) {
235
277
  if (changedHere) {
236
278
  // Only this computer moved: the part goes up.
237
279
  if (local[part] !== undefined) {
238
- merged[part] = snapshot.parts[part];
239
- applied[part] = snapshot.parts[part];
280
+ merged[part] = localParts[part];
281
+ applied[part] = localParts[part];
240
282
  there.push(part);
241
283
  }
284
+ else if (part === 'dirs' && remoteHas) {
285
+ // The list of directories was emptied here. That means "stop syncing these
286
+ // on this computer", not "everybody delete them": the part is carried
287
+ // through as the server has it, so the computers that still name a
288
+ // directory keep syncing it between themselves and no file anywhere is
289
+ // removed for it.
290
+ merged[part] = remotePart();
291
+ }
242
292
  continue;
243
293
  }
244
294
  // Neither side moved. This computer's copy is taken when it has one, so a
245
295
  // part that is present on only one side is never dropped by accident.
246
296
  if (local[part] !== undefined) {
247
- merged[part] = snapshot.parts[part];
248
- applied[part] = snapshot.parts[part];
297
+ merged[part] = localParts[part];
298
+ applied[part] = localParts[part];
249
299
  }
250
300
  else if (remoteHas) {
251
301
  merged[part] = remotePart();
252
- applied[part] = remotePart();
302
+ // A computer that names no directories leaves its disk alone here: writing
303
+ // the server's list back would put files into a folder the user has just
304
+ // taken off that list. Restore is the action that lands in such a folder,
305
+ // and it says so on the card; an ordinary sync has nothing to write.
306
+ if (part !== 'dirs' || config.dirs.length > 0)
307
+ applied[part] = remotePart();
253
308
  }
254
309
  }
255
310
  let saved;
256
311
  if (conflicted)
257
- saved = await keepConflict(engine, snapshot, remote);
312
+ saved = await keepConflict(engine, snapshot, remote, here.includes('dirs') || there.includes('dirs'));
258
313
  else if (first && here.length > 0 && !sameHashes(local, remote.hashes)) {
259
314
  // The server's setup is being taken over one this computer had, with no
260
315
  // baseline to weigh them: a join, or a state file that was lost. Either way
@@ -272,7 +327,10 @@ async function syncOnce(engine, state, force) {
272
327
  if (!pushNeeded) {
273
328
  const written = await applyMerged(engine, snapshot, applied);
274
329
  state.remote = remote.hashes;
275
- state.local = hashesOf((await take(paths)).parts);
330
+ // Read back rather than assume: a directory whose file was just written — or
331
+ // removed — has to be hashed as it now is, or the next sync would see an edit
332
+ // that this one made itself and call it a conflict.
333
+ state.local = hashesOf(partsOf(await take(paths, config), undefined));
276
334
  // A part can come in without the file needing to be written: when only the
277
335
  // server moved, the merge agrees with it. The person still has to be told
278
336
  // what changed here, so the notice is recorded on this path too — clearing
@@ -317,7 +375,7 @@ async function syncOnce(engine, state, force) {
317
375
  * version does not understand. The shapes are checked here instead, before
318
376
  * anything is decided on.
319
377
  *
320
- * Absent parts are fine: a bundle may carry any of the four.
378
+ * Absent parts are fine: a bundle may carry any of the five.
321
379
  */
322
380
  function unreadablePart(bundle) {
323
381
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -325,6 +383,31 @@ function unreadablePart(bundle) {
325
383
  const value = bundle[part];
326
384
  if (value === undefined)
327
385
  continue;
386
+ // The extra directories are an object holding the list, and an empty list is a
387
+ // setup with none rather than a part that cannot be read.
388
+ if (part === 'dirs') {
389
+ if (!isObject(value))
390
+ return part;
391
+ const record = value;
392
+ if (!Array.isArray(record.dirs))
393
+ return part;
394
+ const readable = (one) => {
395
+ if (!isObject(one))
396
+ return false;
397
+ const dir = one;
398
+ if (typeof dir.path !== 'string' || !Array.isArray(dir.files))
399
+ return false;
400
+ return dir.files.every((file) => {
401
+ if (!isObject(file))
402
+ return false;
403
+ const inner = file;
404
+ return typeof inner.path === 'string' && typeof inner.data === 'string';
405
+ });
406
+ };
407
+ if (!record.dirs.every(readable))
408
+ return part;
409
+ continue;
410
+ }
328
411
  if (!isObject(value))
329
412
  return part;
330
413
  const record = value;
@@ -454,13 +537,13 @@ async function push(engine, state, snapshot, merged) {
454
537
  // the one that was there before: the guard is asking, next time, whether the
455
538
  // bytes on the server are still the ones it wrote.
456
539
  const before = { sum: sum(sealed), local: state.local, remote: state.remote };
457
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
540
+ const dav = davOf(config);
458
541
  const written = await dav.put(sealed, merged.version.etag ?? '');
459
542
  // The parts are written here only after the write to the server succeeded, so a
460
543
  // failed upload never leaves this computer holding another computer's setup
461
544
  // with nothing to say where its own went.
462
545
  const applied = await applyMerged(engine, snapshot, merged.applied);
463
- const now = hashesOf((await take(paths)).parts);
546
+ const now = hashesOf(partsOf(await take(paths, config), undefined));
464
547
  state.server = written;
465
548
  state.sum = sum(sealed);
466
549
  state.remote = hashesOf(bundle);
@@ -496,9 +579,35 @@ async function push(engine, state, snapshot, merged) {
496
579
  * profile.
497
580
  */
498
581
  async function applyMerged(engine, snapshot, applied) {
499
- const { paths, config } = engine;
582
+ const { paths } = engine;
500
583
  let reloadNeeded = false;
501
584
  let kept = false;
585
+ // The directories are written first so that, when they are written at all, the
586
+ // copy kept for the way back is the one that carries them ({@link keepBefore}).
587
+ // `null` is "the other computer took this part away" — the instruction the other
588
+ // parts obey by deleting a row. Here it means nothing at all on disk: a sync
589
+ // never removes a file from somebody's own directory, so a removed part leaves
590
+ // what is there exactly as it is.
591
+ if (applied.dirs !== undefined && applied.dirs !== null) {
592
+ const part = applied.dirs;
593
+ // A directory this run could not read is not written into: what is there now
594
+ // was never read, so replacing it would be writing blind. A directory that is
595
+ // merely absent is not in this set — creating it is what a restore is for. See
596
+ // {@link writeDirs}.
597
+ const skip = new Set(snapshot.dirs.problems.filter((problem) => problem.missing !== true).map((problem) => problem.path));
598
+ // Compared with the disk rather than with the configured list, so that a
599
+ // computer which syncs no directories of its own — but is sent somebody's, which
600
+ // is how a restore lands in the same folder — does not seal a copy before a write
601
+ // that changes nothing. The copy is what Undo puts back, so it has to carry the
602
+ // directories whenever they really are about to be written to.
603
+ if (await dirsDiffer(part, skip)) {
604
+ kept = true;
605
+ await keepBefore(engine, snapshot, true);
606
+ const wrote = await writeDirs(part, skip);
607
+ if (wrote.changed)
608
+ reloadNeeded = true;
609
+ }
610
+ }
502
611
  if (applied.models !== undefined || applied.defaults !== undefined || applied.patch !== undefined) {
503
612
  // `null` means "take this away", which is how a removal made on another
504
613
  // computer reaches this one. The patch part has no row of its own to remove,
@@ -528,15 +637,23 @@ async function applyMerged(engine, snapshot, applied) {
528
637
  }
529
638
  return { reloadNeeded };
530
639
  }
531
- /** Seal this computer's setup into `sync/before-apply` before a merge writes. */
532
- async function keepBefore(engine, snapshot) {
640
+ /**
641
+ * Seal this computer's setup into `sync/before-apply` before a merge writes.
642
+ *
643
+ * `withDirs` is on when the write about to happen touches the extra directories,
644
+ * because that copy is what Undo puts back and a directory left out of it could not
645
+ * be put back. It is off otherwise: a directory tree can run to megabytes, and a
646
+ * copy taken so that one patch row can be undone has no business carrying it.
647
+ */
648
+ async function keepBefore(engine, snapshot, withDirs = false) {
533
649
  try {
650
+ const parts = withDirs ? partsOf(snapshot, undefined) : { ...snapshot.parts };
534
651
  const bundle = {
535
652
  format: BUNDLE_FORMAT,
536
653
  version: BUNDLE_VERSION,
537
654
  created: new Date().toISOString(),
538
655
  app: BUNDLE_APP,
539
- ...snapshot.parts,
656
+ ...parts,
540
657
  };
541
658
  const sealed = await seal(bundle, engine.config.passphrase);
542
659
  return await keepAside(keptDir(engine.paths), 'before-apply', sealed);
@@ -547,15 +664,16 @@ async function keepBefore(engine, snapshot) {
547
664
  }
548
665
  }
549
666
  /** Keep both sides of a conflict, so the one that lost is still readable. */
550
- async function keepConflict(engine, snapshot, remote) {
667
+ async function keepConflict(engine, snapshot, remote, withDirs = false) {
551
668
  const { paths, config } = engine;
552
669
  const dir = keptDir(paths);
670
+ const parts = withDirs ? partsOf(snapshot, undefined) : { ...snapshot.parts };
553
671
  const ours = {
554
672
  format: BUNDLE_FORMAT,
555
673
  version: BUNDLE_VERSION,
556
674
  created: new Date().toISOString(),
557
675
  app: BUNDLE_APP,
558
- ...snapshot.parts,
676
+ ...parts,
559
677
  };
560
678
  await keepAside(dir, 'this-computer', await seal(ours, config.passphrase)).catch(() => undefined);
561
679
  if (remote.body !== undefined) {
@@ -586,7 +704,7 @@ async function keepConflict(engine, snapshot, remote) {
586
704
  * nothing.
587
705
  */
588
706
  async function keepUnmerged(engine, snapshot) {
589
- const kept = await keepBefore(engine, snapshot);
707
+ const kept = await keepBefore(engine, snapshot, true);
590
708
  return kept === undefined ? undefined : keptDir(engine.paths);
591
709
  }
592
710
  /**
@@ -625,7 +743,7 @@ export async function restore(engine) {
625
743
  throw new Error('同步还没有配置好:请先填写地址和口令');
626
744
  await ensureStateDir(paths);
627
745
  const state = await freshState(paths, config);
628
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
746
+ const dav = davOf(config);
629
747
  const read = await dav.get();
630
748
  if (read.notModified || read.data === undefined) {
631
749
  if (read.data === undefined)
@@ -634,8 +752,13 @@ export async function restore(engine) {
634
752
  const remote = await openRemote(engine, state, { ...read, notModified: false });
635
753
  if (remote.empty)
636
754
  throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
637
- const snapshot = await take(paths);
638
- const local = hashesOf(snapshot.parts);
755
+ const snapshot = await take(paths, config);
756
+ // What is here *on disk* is what the notice is about, so the directories are
757
+ // hashed without standing in the server's copy for the ones this computer could
758
+ // not read: a folder that is merely absent reads as nothing here, differs from
759
+ // the server's copy, and is therefore named as brought in — which is what a
760
+ // restore that creates it has just done.
761
+ const local = hashesOf(partsOf(snapshot, undefined));
639
762
  const here = [];
640
763
  const merged = {};
641
764
  const applied = {};
@@ -655,16 +778,20 @@ export async function restore(engine) {
655
778
  if (local[part] !== remote.hashes[part])
656
779
  here.push(part);
657
780
  }
658
- const kept = await keepBefore(engine, snapshot);
781
+ const kept = await keepBefore(engine, snapshot, true);
659
782
  const written = await applyMerged(engine, snapshot, applied);
660
783
  state.undo = kept ?? '';
661
784
  state.remote = remote.hashes;
662
- state.local = hashesOf((await take(paths)).parts);
785
+ state.local = hashesOf(partsOf(await take(paths, config), undefined));
663
786
  state.sum = read.data === undefined ? state.sum : sum(read.data);
664
787
  state.server = read.version;
665
788
  state.blind = undefined;
666
789
  state.last = new Date().toISOString();
667
790
  state.error = '';
791
+ // Every part named in `here` was written over this computer's copy, or was
792
+ // taken away for Undo; a restore that named none found the two sides alike.
793
+ if (here.length > 0)
794
+ state.moved = state.last;
668
795
  state.notice = { at: new Date().toISOString(), here, there: [], ...(kept === undefined ? {} : { saved: keptDir(paths) }), restored: true };
669
796
  if (read.data !== undefined)
670
797
  await remember(paths, read.data);
@@ -694,7 +821,7 @@ export async function upload(engine) {
694
821
  throw new Error('同步还没有配置好:请先填写地址和口令');
695
822
  await ensureStateDir(paths);
696
823
  const state = await freshState(paths, config);
697
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
824
+ const dav = davOf(config);
698
825
  const read = await dav.get();
699
826
  if (read.data !== undefined) {
700
827
  let readable = true;
@@ -715,9 +842,9 @@ export async function upload(engine) {
715
842
  }
716
843
  await keepAside(keptDir(paths), 'server-replaced', read.data);
717
844
  }
718
- const snapshot = await take(paths);
845
+ const snapshot = await take(paths, config);
719
846
  const result = await push(engine, state, snapshot, {
720
- merged: { ...snapshot.parts },
847
+ merged: { ...partsOf(snapshot, undefined) },
721
848
  applied: {},
722
849
  remote: {},
723
850
  version: read.version,
@@ -726,6 +853,9 @@ export async function upload(engine) {
726
853
  });
727
854
  state.last = new Date().toISOString();
728
855
  state.error = '';
856
+ // An upload is this computer's setup going up wholesale, whatever the server
857
+ // held before: it moved something even when it was asked for as a rescue.
858
+ state.moved = state.last;
729
859
  await saveState(paths, state);
730
860
  return result;
731
861
  });
@@ -746,7 +876,7 @@ export async function undo(engine) {
746
876
  throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
747
877
  }
748
878
  const bundle = (await open(new Uint8Array(data), config.passphrase));
749
- const snapshot = await take(paths);
879
+ const snapshot = await take(paths, config);
750
880
  const merged = {};
751
881
  const applied = {};
752
882
  const back = [];
@@ -792,8 +922,8 @@ export async function status(engine) {
792
922
  const { paths, config } = engine;
793
923
  const state = await loadState(paths);
794
924
  const fresh = state.key === stateKey(config);
795
- const snapshot = await take(paths).catch(() => undefined);
796
- const local = snapshot === undefined ? {} : hashesOf(snapshot.parts);
925
+ const snapshot = await take(paths, config).catch(() => undefined);
926
+ const local = snapshot === undefined ? {} : hashesOf(partsOf(snapshot, undefined));
797
927
  const parts = [];
798
928
  const known = fresh ? state.local : undefined;
799
929
  const knownRemote = fresh ? state.remote : undefined;
@@ -842,11 +972,17 @@ export async function status(engine) {
842
972
  packageFile: paths.packageFile,
843
973
  },
844
974
  ...(fresh && state.last !== undefined && state.last !== '' ? { last: state.last } : {}),
975
+ ...(fresh && state.moved !== undefined && state.moved !== '' ? { moved: state.moved } : {}),
845
976
  error: errorApplies && typeof state.error === 'string' ? state.error : '',
846
977
  ...(fresh && state.notice !== undefined ? { notice: state.notice } : {}),
847
978
  parts,
848
979
  pendingInstall: missing.length > 0,
849
980
  ...(missing.length > 0 ? { pendingNames: missing } : {}),
981
+ // A directory that could not be read is said here rather than kept quiet: it
982
+ // is the one thing about this feature the person has to act on.
983
+ ...(snapshot === undefined || snapshot.dirs.problems.length === 0
984
+ ? {}
985
+ : { dirProblems: snapshot.dirs.problems }),
850
986
  undoable: fresh && state.undo !== undefined && state.undo !== '',
851
987
  };
852
988
  }
@@ -872,6 +1008,19 @@ export async function turnOff(engine) {
872
1008
  // host would send, and turning sync off is the way to clear one.
873
1009
  releasePassword(engine.paths);
874
1010
  }
1011
+ /**
1012
+ * The client one configuration reaches: its address, its account, and the extra
1013
+ * headers the card configured. Parsed once here rather than at every call, so a
1014
+ * malformed configured line is refused before the first request goes out.
1015
+ */
1016
+ function davOf(config) {
1017
+ return new Dav({
1018
+ url: config.url,
1019
+ user: config.user,
1020
+ password: config.password,
1021
+ headers: parseHeaders(config.headers),
1022
+ });
1023
+ }
875
1024
  /** The configuration as the card sees it: never a secret, only whether one is set. */
876
1025
  export function viewOf(config) {
877
1026
  return {
@@ -884,7 +1033,12 @@ export function viewOf(config) {
884
1033
  defaults: config.defaults,
885
1034
  plugins: config.plugins,
886
1035
  patch: config.patch,
1036
+ dirs: [...config.dirs],
1037
+ dirsEnabled: config.dirsEnabled,
887
1038
  rememberPassword: config.rememberPassword,
1039
+ headers: config.headers,
1040
+ relayEnabled: config.relayEnabled,
1041
+ relayPort: config.relayPort,
888
1042
  };
889
1043
  }
890
1044
  // ---------------------------------------------------------------------------
@@ -895,7 +1049,7 @@ export async function check(engine) {
895
1049
  const { config } = engine;
896
1050
  if (normaliseUrl(config.url) === '')
897
1051
  throw new Error('请先填写同步地址');
898
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
1052
+ const dav = davOf(config);
899
1053
  try {
900
1054
  await dav.check();
901
1055
  }
@@ -946,11 +1100,11 @@ export async function preview(engine) {
946
1100
  if (!usable(config))
947
1101
  throw new Error('同步还没有配置好:请先填写地址和口令');
948
1102
  const state = await freshState(paths, config);
949
- const dav = new Dav({ url: config.url, user: config.user, password: config.password });
1103
+ const dav = davOf(config);
950
1104
  const read = await dav.get(state.server);
951
1105
  const remote = await openRemote(engine, state, read);
952
- const snapshot = await take(paths);
953
- const local = hashesOf(snapshot.parts);
1106
+ const snapshot = await take(paths, config);
1107
+ const local = hashesOf(partsOf(snapshot, remote.part('dirs')));
954
1108
  const first = state.local === undefined;
955
1109
  const changes = [];
956
1110
  for (const part of PARTS) {
@@ -976,10 +1130,15 @@ export async function preview(engine) {
976
1130
  // Small helpers
977
1131
  // ---------------------------------------------------------------------------
978
1132
  /** Read this computer's setup and the files it came from, in one pass. */
979
- export async function take(paths) {
1133
+ export async function take(paths, config) {
980
1134
  const patch = await readPatch(paths);
981
1135
  const manifest = await readManifest(paths);
982
- return { patch, manifest, parts: collect(patch, manifest) };
1136
+ // Switched off, the directories are not walked at all: reading them is the one
1137
+ // part of a snapshot that can cost a great deal, and a part this configuration
1138
+ // does not carry has nothing to say. An empty read still tells the merge there is
1139
+ // nothing here to compare, which is exactly true.
1140
+ const dirs = await readDirs(config.dirsEnabled ? config.dirs : []);
1141
+ return { patch, manifest, parts: collect(patch, manifest), dirs };
983
1142
  }
984
1143
  /** Per-part hashes of a setup; a part that is not there has none. */
985
1144
  export function hashesOf(parts) {
@@ -1006,6 +1165,10 @@ export function sameHashes(a, b) {
1006
1165
  export function localChangedAt(snapshot, part) {
1007
1166
  if (part === 'plugins')
1008
1167
  return snapshot.manifest.mtimeMs;
1168
+ // The extra directories have no file of their own to point at, so the newest
1169
+ // file inside any of them stands for the whole part.
1170
+ if (part === 'dirs')
1171
+ return snapshot.dirs.mtimeMs;
1009
1172
  return snapshot.patch.mtimeMs;
1010
1173
  }
1011
1174
  /** The top-level packages present in the profile's node_modules. */
package/lib/index.d.ts CHANGED
@@ -20,8 +20,6 @@ import { sameAccount, usable, type Engine } from './engine.js';
20
20
  import { defaultConfig, normaliseConfig } from './state.js';
21
21
  export declare const name = "dsh-webdav-sync";
22
22
  export declare const inject: never[];
23
- /** The settings namespace this plugin serves; its browser card spells the same string. */
24
- export declare const WEBDAV_SYNC_NS = "dsh-webdav-sync";
25
23
  export interface Config {
26
24
  /** Profile directory to sync; absent selects the running profile. */
27
25
  profileDir?: Volatile<string | undefined> | string;
@@ -36,13 +34,6 @@ export interface Config {
36
34
  export declare const Config: z;
37
35
  /** Reject stale or misspelled config keys before defaults can hide them. */
38
36
  export declare function validateConfig(config: Config): void;
39
- /** The settings-facing subset of the config. */
40
- export interface SectionConfig {
41
- profileDir?: string;
42
- autoEvery: number;
43
- }
44
- /** The section-shaped view of a config: absent keys stay absent. */
45
- export declare function sectionOf(config: Config): SectionConfig;
46
37
  /**
47
38
  * Apply one configuration change from the card.
48
39
  *
@@ -59,16 +50,14 @@ export declare function configure(engine: Engine, body: ConfigureBody): Promise<
59
50
  /**
60
51
  * Plugin apply: mount the settings presentation policy, the HTTP routes and the
61
52
  * auto-sync timer on any webServer that comes and goes.
62
- */
63
- /**
64
- * Passwords that are in use but deliberately not on disk.
65
53
  *
66
- * "Remember the password" off means it must not be written to `sync.json`, and
67
- * it does not mean it should stop working: every route rebuilds its engine from
68
- * that file, so a password emptied there would be one no request could carry and
69
- * sync would be unable to sign in until the process restarted. It is held for as
70
- * long as the host runs, keyed by the state directory so two profiles do not
71
- * share one, and put back into the configuration each time it is read.
54
+ * Passwords that are in use but deliberately not on disk are read back here:
55
+ * "remember the password" off means it must not be written to `sync.json`, and it
56
+ * does not mean it should stop working. Every route rebuilds its engine from that
57
+ * file, so a password emptied there would be one no request could carry and sync
58
+ * would be unable to sign in until the process restarted. It is held for as long
59
+ * as the host runs, keyed by the state directory so two profiles do not share
60
+ * one, and put back into the configuration each time it is read.
72
61
  */
73
62
  export declare function apply(ctx: Context, config?: Config): void;
74
63
  export { defaultConfig, normaliseConfig, sameAccount, usable };