dsh-webdav-sync 0.0.0-stage → 1.0.0

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/dav.js ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.dshsync`,
3
+ * and the whole protocol between this computer and that file is:
4
+ *
5
+ * - read it, with the version last seen so an unchanged file costs one request;
6
+ * - write it over the version that was read, so another computer's write in
7
+ * between is refused rather than silently overwritten.
8
+ *
9
+ * The servers that answer are as varied as the people who run them: 坚果云
10
+ * (Nutstore) counts requests per account and answers a read of a folder that is
11
+ * not there with 409 rather than 404; Synology answers 403 for a file in a
12
+ * missing folder; Apache's mod_deflate rewrites an ETag for the compressed copy
13
+ * it hands back, which then never matches in `If-Match`. Every one of those is
14
+ * handled where it happens, with the reason it exists written beside it.
15
+ */
16
+ import { REMOTE_FILE, REMOTE_FOLDER } from './wire.js';
17
+ /** The file was not written because it changed on the server since it was read. */
18
+ export class ChangedError extends Error {
19
+ constructor() {
20
+ super('本机写入期间,服务器上的文件变了:请再同步一次,看看它现在是什么');
21
+ this.name = 'ChangedError';
22
+ }
23
+ }
24
+ /** The file is still the version last read; the server did not send it again. */
25
+ export class NotModifiedError extends Error {
26
+ constructor() {
27
+ super('服务器上的文件没有变化');
28
+ this.name = 'NotModifiedError';
29
+ }
30
+ }
31
+ /** The server refused the account. */
32
+ export class LoginError extends Error {
33
+ constructor() {
34
+ super('WebDAV 服务器拒绝了用户名或密码(HTTP 401)');
35
+ this.name = 'LoginError';
36
+ }
37
+ }
38
+ /** The server is limiting how often it is reached, or is busy. */
39
+ export class RateLimitedError extends Error {
40
+ status;
41
+ after;
42
+ constructor(status, after) {
43
+ super(status === 429
44
+ ? 'WebDAV 服务器限制了本机的访问频率(HTTP 429):下次同步会多等一会儿再试'
45
+ : `WebDAV 服务器忙,或者限制了访问频率(HTTP ${status}):下次同步会多等一会儿再试`);
46
+ this.status = status;
47
+ this.after = after;
48
+ this.name = 'RateLimitedError';
49
+ }
50
+ }
51
+ /** A 403 the server would not explain; carries the folder the address names. */
52
+ export class ForbiddenError extends Error {
53
+ explained;
54
+ constructor(message, explained) {
55
+ super(message);
56
+ this.explained = explained;
57
+ this.name = 'ForbiddenError';
58
+ }
59
+ }
60
+ /** Largest file this plugin will read back from a server. */
61
+ const MAX_BYTES = 64 * 1024 * 1024;
62
+ /** How long a single request may take before it is abandoned. */
63
+ const REQUEST_TIMEOUT_MS = 120_000;
64
+ /** A resolved WebDAV endpoint. */
65
+ export class Dav {
66
+ base;
67
+ /**
68
+ * The address's own path, without a trailing slash, and empty for a server
69
+ * root. It is kept aside because a URL cannot hold that emptiness: assigning
70
+ * `''` to a pathname puts the `/` straight back, and joining onto that `/`
71
+ * would build `//dsh-webdav`, which servers answer 400 to.
72
+ */
73
+ basePath;
74
+ user;
75
+ password;
76
+ constructor(options) {
77
+ const trimmed = (options.url ?? '').trim();
78
+ let parsed;
79
+ try {
80
+ parsed = new URL(trimmed);
81
+ }
82
+ catch {
83
+ throw new Error(`「${trimmed}」不是 WebDAV 地址(形如 https://…)`);
84
+ }
85
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
86
+ throw new Error(`「${trimmed}」不是 WebDAV 地址(形如 https://…)`);
87
+ }
88
+ if (parsed.host === '')
89
+ throw new Error(`「${trimmed}」不是 WebDAV 地址(形如 https://…)`);
90
+ const path = parsed.pathname.replace(/\/+$/, '');
91
+ parsed.pathname = path;
92
+ this.base = parsed;
93
+ this.basePath = path;
94
+ this.user = options.user ?? '';
95
+ this.password = options.password ?? '';
96
+ }
97
+ /** The address, without any credentials that may have ridden in on it. */
98
+ get address() {
99
+ const shown = new URL(this.base.toString());
100
+ shown.username = '';
101
+ shown.password = '';
102
+ return shown.toString().replace(/\/$/, '');
103
+ }
104
+ /** The full URL of one path under the address. */
105
+ url(...parts) {
106
+ const url = new URL(this.base.toString());
107
+ url.pathname = `${this.basePath}/${parts.join('/')}`;
108
+ return url.toString();
109
+ }
110
+ /** Where the backup file itself lives. */
111
+ get fileUrl() {
112
+ return this.url(REMOTE_FOLDER, REMOTE_FILE);
113
+ }
114
+ /**
115
+ * Read the backup.
116
+ *
117
+ * Returns `undefined` when the server holds none yet — which several servers
118
+ * say in several ways. Throws {@link NotModifiedError} when `have` is still
119
+ * current, {@link LoginError}, {@link RateLimitedError} or {@link ForbiddenError}.
120
+ */
121
+ async get(have) {
122
+ const headers = { 'accept-encoding': 'identity' };
123
+ // The read asks for the file uncompressed because the ETag it returns is what
124
+ // the write after it matches with If-Match, and a server that compresses
125
+ // changes that ETag for the compressed copy.
126
+ if (have?.etag !== undefined && have.etag !== '')
127
+ headers['if-none-match'] = have.etag;
128
+ else if (have?.modified !== undefined && have.modified !== '')
129
+ headers['if-modified-since'] = have.modified;
130
+ const conditional = headers['if-none-match'] !== undefined || headers['if-modified-since'] !== undefined;
131
+ const response = await this.send('GET', this.fileUrl, undefined, headers);
132
+ if (response.status === 304 && conditional) {
133
+ return { version: have ?? {}, notModified: true };
134
+ }
135
+ if (response.status === 403) {
136
+ // A Synology answers 403, not 404, for a file in a folder that is not
137
+ // there — which is the state before the first sync.
138
+ if (!(await this.there(`${this.url(REMOTE_FOLDER)}/`)))
139
+ return { version: {} };
140
+ throw await this.forbiddenMessage();
141
+ }
142
+ if (response.status === 404 || response.status === 410 || response.status === 409) {
143
+ // 409 is how 坚果云 answers a read of a folder that is not there yet.
144
+ return { version: {} };
145
+ }
146
+ if (response.status !== 200) {
147
+ await drain(response);
148
+ throw new Error(`从 WebDAV 服务器读取 ${REMOTE_FILE} 失败:HTTP ${response.status}`);
149
+ }
150
+ const buffer = new Uint8Array(await response.arrayBuffer());
151
+ if (buffer.byteLength > MAX_BYTES) {
152
+ throw new Error(`WebDAV 服务器上的文件超过 ${MAX_BYTES} 字节,没有读取`);
153
+ }
154
+ const data = await unpacked(buffer, response.headers.get('content-encoding'));
155
+ // A redirect that ended somewhere other than the file — a web app's sign-in
156
+ // or files page — is carried out with where it went, so the caller can say
157
+ // the address is the thing to fix rather than call the page a damaged file.
158
+ if (response.redirected) {
159
+ const to = new URL(response.url);
160
+ to.search = '';
161
+ to.hash = '';
162
+ to.username = '';
163
+ to.password = '';
164
+ return { data, version: versionOf(response.headers), redirectedTo: `${to.host}${to.pathname}` };
165
+ }
166
+ return { data, version: versionOf(response.headers) };
167
+ }
168
+ /**
169
+ * Write the backup, only over the version that was read when there was one.
170
+ *
171
+ * Relays and tunnels cut long writes short, and the half that lands reads back
172
+ * as damage on every sync after, so what a write kept is read back and
173
+ * compared; a short write is tried again.
174
+ */
175
+ async put(data, etag) {
176
+ const headers = { 'content-type': 'application/octet-stream' };
177
+ if (etag !== undefined && etag !== '')
178
+ headers['if-match'] = etag;
179
+ for (let attempt = 0, writes = 0;; attempt++) {
180
+ const response = await this.send('PUT', this.fileUrl, data, headers);
181
+ if (response.status >= 200 && response.status < 300) {
182
+ writes++;
183
+ const serverEtag = response.headers.get('etag') ?? '';
184
+ await drain(response);
185
+ const kept = await this.verifyLength(this.fileUrl, data.byteLength);
186
+ if (kept.short) {
187
+ if (writes < 3) {
188
+ // The half file is a new version: the retry matches it — the ETag the
189
+ // PUT answered, else the one the check read; with neither, the old one
190
+ // goes, for it can no longer be current.
191
+ const next = serverEtag !== '' ? serverEtag : kept.etag;
192
+ if (next !== '')
193
+ headers['if-match'] = next;
194
+ else
195
+ delete headers['if-match'];
196
+ continue;
197
+ }
198
+ throw new Error(`WebDAV 服务器只收下了 ${REMOTE_FILE} 的 ${kept.length}/${data.byteLength} 字节:写入被截断了——请再同步一次;如果一直如此,说明到服务器的网络在丢弃长上传`);
199
+ }
200
+ return serverEtag === '' ? {} : { etag: serverEtag };
201
+ }
202
+ await drain(response);
203
+ if (response.status === 412)
204
+ throw new ChangedError();
205
+ if (response.status === 403 && (attempt > 0 || (await this.there(`${this.url(REMOTE_FOLDER)}/`)))) {
206
+ throw await this.forbiddenMessage();
207
+ }
208
+ if ((response.status === 404 || response.status === 409 || response.status === 403) && attempt === 0) {
209
+ await this.mkcol();
210
+ continue;
211
+ }
212
+ if (response.status === 404 || response.status === 409) {
213
+ throw await this.nowhereMessage(response.status);
214
+ }
215
+ throw new Error(`向 WebDAV 服务器写入 ${REMOTE_FILE} 失败:HTTP ${response.status}`);
216
+ }
217
+ }
218
+ /** Whether the address is reachable and the account may read and write in it. */
219
+ async check() {
220
+ const listing = await this.propfind(this.url(), '0');
221
+ if (listing.status === 401)
222
+ throw new LoginError();
223
+ if (listing.status === 429 || listing.status === 503)
224
+ throw this.limited(listing);
225
+ if (listing.status === 403)
226
+ throw await this.forbiddenMessage();
227
+ if (listing.status === 404 || listing.status === 409 || listing.status === 410) {
228
+ throw new Error(`WebDAV 服务器上没有目录 ${displayPath(this.base)}:请先在那边建好,或者把它从地址里去掉`);
229
+ }
230
+ if (listing.status >= 300) {
231
+ throw new Error(`WebDAV 服务器对 ${displayPath(this.base)} 回答了 ${listing.status}`);
232
+ }
233
+ // Reading the address is not writing in it, so the folder is made (or found)
234
+ // here — that is the first thing a first sync does anyway.
235
+ const folder = await this.propfind(`${this.url(REMOTE_FOLDER)}/`, '0');
236
+ if (folder.status === 404 || folder.status === 409 || folder.status === 410)
237
+ await this.mkcol();
238
+ else if (folder.status === 401)
239
+ throw new LoginError();
240
+ else if (folder.status === 403)
241
+ throw await this.forbiddenMessage();
242
+ else if (folder.status >= 300) {
243
+ // Anything else is a refusal this check cannot call success: a 400 here was
244
+ // once reported as "the server answered" while every later sync failed.
245
+ await drain(folder);
246
+ throw new Error(`WebDAV 服务器对 ${displayPath(this.base)} 里的目录 ${REMOTE_FOLDER} 回答了 ${folder.status}:地址必须是一个可以写入文件的目录`);
247
+ }
248
+ await drain(folder);
249
+ return { ok: true };
250
+ }
251
+ /** Read back what the file holds now, for the check action's report. */
252
+ async stat() {
253
+ const head = await this.send('HEAD', this.fileUrl, undefined, {});
254
+ const status = head.status;
255
+ if (status === 200) {
256
+ const declared = Number(head.headers.get('content-length') ?? '');
257
+ await drain(head);
258
+ if (Number.isFinite(declared) && declared > 0)
259
+ return { exists: true, size: declared };
260
+ // A server may report a stored file's length as 0 on HEAD (坚果云 answers
261
+ // 0 for a file it holds in full, magpie #1259), so a zero — or no length
262
+ // at all — is asked of PROPFIND before it is believed.
263
+ const found = await this.length(this.fileUrl);
264
+ if (found !== undefined && found > 0)
265
+ return { exists: true, size: found };
266
+ return Number.isFinite(declared) && declared >= 0 ? { exists: true, size: declared } : { exists: true };
267
+ }
268
+ await drain(head);
269
+ if (status === 401)
270
+ throw new LoginError();
271
+ if (status === 429 || status === 503)
272
+ throw this.limited(head);
273
+ // 405: the server does not do HEAD, so the size is asked of PROPFIND instead.
274
+ // 403/404/409/410: no file yet, which is not an error here.
275
+ const found = await this.length(this.fileUrl);
276
+ return found === undefined ? { exists: false } : { exists: true, size: found };
277
+ }
278
+ /**
279
+ * Make the folder, tolerating the several ways servers say it is already
280
+ * there or that the folder above it is missing.
281
+ */
282
+ async mkcol() {
283
+ const response = await this.send('MKCOL', `${this.url(REMOTE_FOLDER)}/`, undefined, {});
284
+ const status = response.status;
285
+ await drain(response);
286
+ if (status === 409) {
287
+ throw new Error(`WebDAV 服务器上没有目录 ${displayPath(this.base)}(HTTP 409):请先在那边建好,或者把它从地址里去掉`);
288
+ }
289
+ // 405: it is there already. 301/302: some servers answer the collection's
290
+ // creation with a redirect to it.
291
+ if (status >= 300 && status !== 405 && status !== 301 && status !== 302) {
292
+ throw new Error(`在 WebDAV 服务器上创建目录 ${REMOTE_FOLDER} 失败:HTTP ${status}`);
293
+ }
294
+ }
295
+ /**
296
+ * Say why a 403 was: servers answer it for a folder that is not there as much
297
+ * as for one the account may not write in, so the folder is looked at first.
298
+ */
299
+ async forbiddenMessage() {
300
+ const dir = displayPath(this.base);
301
+ const probe = await this.propfind(this.url(), '0');
302
+ if (probe.status === 401)
303
+ return new ForbiddenError(new LoginError().message, true);
304
+ if (probe.status === 404 || probe.status === 409 || probe.status === 410) {
305
+ return new ForbiddenError(`WebDAV 服务器上没有目录 ${dir}(HTTP 403):请先在那边建好,或者把它从地址里去掉`, true);
306
+ }
307
+ if (probe.status >= 200 && probe.status < 300) {
308
+ return new ForbiddenError(`WebDAV 服务器不允许这个账号写入 ${dir}(HTTP 403):请确认这个账号可以改动那个目录里的文件`, true);
309
+ }
310
+ return new ForbiddenError(`WebDAV 服务器不允许这个账号使用 ${dir}(HTTP 403):目录可能不在那里,或者这个账号没有访问它的权限`, false);
311
+ }
312
+ /**
313
+ * The folder a file could not be put in, named with the storages the address
314
+ * does hold — OpenList's and Alist's `/dav/` is a list of their storages, where
315
+ * MKCOL answers 405 and PUT 404, so the address has to go into one of them.
316
+ */
317
+ async nowhereMessage(code) {
318
+ const dir = displayPath(this.base);
319
+ const { entries, status } = await this.list(this.url());
320
+ if (status === 404 || status === 409 || status === 410) {
321
+ return new Error(`WebDAV 服务器上没有目录 ${dir}(HTTP ${code}):请先在那边建好,或者把它从地址里去掉`);
322
+ }
323
+ let message = `WebDAV 服务器在 ${dir} 里无处安放目录 ${REMOTE_FOLDER}(HTTP ${code}):地址必须是一个可以写入文件的目录`;
324
+ if (entries.length > 0) {
325
+ const child = new URL(this.base.toString());
326
+ child.pathname = `${this.basePath}/${entries[0]}`;
327
+ message += `——在 OpenList 或 Alist 上,用它下面的某个存储(${entries.join('、')}),例如 ${child.toString()}`;
328
+ }
329
+ return new Error(message);
330
+ }
331
+ /** Whether PROPFIND finds a URL: a server answering 403 for what is not there may do so here too. */
332
+ async there(url) {
333
+ try {
334
+ const response = await this.propfind(url, '0');
335
+ await drain(response);
336
+ return response.status >= 200 && response.status < 300;
337
+ }
338
+ catch {
339
+ return true;
340
+ }
341
+ }
342
+ /**
343
+ * The size a write kept, read back. `short` is true only when a real size was
344
+ * found and it is less than what was sent: a HEAD that cannot be done leaves
345
+ * the write unchecked, because it landed either way.
346
+ */
347
+ async verifyLength(url, want) {
348
+ let response;
349
+ try {
350
+ response = await this.send('HEAD', url, undefined, {});
351
+ }
352
+ catch {
353
+ return { short: false, length: want, etag: '' };
354
+ }
355
+ if (response.status !== 200) {
356
+ await drain(response);
357
+ const found = await this.length(url);
358
+ return found === undefined
359
+ ? { short: false, length: want, etag: '' }
360
+ : { short: found < want, length: found, etag: '' };
361
+ }
362
+ const etag = response.headers.get('etag') ?? '';
363
+ const declared = Number(response.headers.get('content-length') ?? '');
364
+ await drain(response);
365
+ if (!Number.isFinite(declared) || declared < 0 || declared >= want) {
366
+ return { short: false, length: want, etag };
367
+ }
368
+ // A HEAD's Content-Length is not always the file's: 坚果云 answered a
369
+ // 6869-byte file's with 0 on every try. The length WebDAV keeps is
370
+ // PROPFIND's getcontentlength, so a short HEAD is asked of that before it is
371
+ // believed; with no length there, the HEAD's stands.
372
+ const found = await this.length(url);
373
+ if (found === undefined)
374
+ return { short: true, length: declared, etag };
375
+ return { short: found < want, length: found, etag };
376
+ }
377
+ /** The file's size as PROPFIND (Depth 0) reports it; `undefined` when it says none. */
378
+ async length(url) {
379
+ const response = await this.propfind(url, '0');
380
+ if (response.status !== 207) {
381
+ await drain(response);
382
+ return undefined;
383
+ }
384
+ const body = await response.text();
385
+ const match = /<[^>]*getcontentlength[^>]*>\s*(\d+)\s*</i.exec(body);
386
+ if (match === null)
387
+ return undefined;
388
+ const value = Number(match[1] ?? '');
389
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
390
+ }
391
+ /** The child collections of a URL, as PROPFIND (Depth 1) lists them, and the status. */
392
+ async list(url) {
393
+ const response = await this.propfind(url, '1');
394
+ if (response.status !== 207) {
395
+ await drain(response);
396
+ return { entries: [], status: response.status };
397
+ }
398
+ const body = await response.text();
399
+ const self = this.basePath;
400
+ const entries = [];
401
+ for (const block of body.split(/<[^>]*response[\s>]/i).slice(1)) {
402
+ if (!/<[^>]*collection/i.test(block))
403
+ continue;
404
+ const href = /<[^>]*href[^>]*>([\s\S]*?)</i.exec(block);
405
+ if (href === null)
406
+ continue;
407
+ let path = decodeXml(href[1] ?? '');
408
+ try {
409
+ path = new URL(path, url).pathname;
410
+ }
411
+ catch {
412
+ // A href that is already a path is used as it came.
413
+ }
414
+ path = path.replace(/\/+$/, '');
415
+ const parent = path.slice(0, path.lastIndexOf('/'));
416
+ if (path === self || (parent !== self && !(self === '' && parent === '')))
417
+ continue;
418
+ const name = path.slice(path.lastIndexOf('/') + 1);
419
+ if (name !== '' && !entries.includes(name))
420
+ entries.push(name);
421
+ if (entries.length >= 5)
422
+ break;
423
+ }
424
+ return { entries, status: response.status };
425
+ }
426
+ /** One PROPFIND, with the Depth header the caller asks for. */
427
+ async propfind(url, depth) {
428
+ return this.send('PROPFIND', url, undefined, { depth });
429
+ }
430
+ /** One request, with auth, redirect and rate-limit handling applied. */
431
+ async send(method, url, body, headers = {}) {
432
+ const requestHeaders = new Headers();
433
+ for (const [key, value] of Object.entries(headers))
434
+ requestHeaders.set(key, value);
435
+ if (this.user !== '' || this.password !== '') {
436
+ requestHeaders.set('authorization', `Basic ${Buffer.from(`${this.user}:${this.password}`, 'utf8').toString('base64')}`);
437
+ }
438
+ let response;
439
+ try {
440
+ const init = {
441
+ method,
442
+ headers: requestHeaders,
443
+ redirect: 'follow',
444
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
445
+ };
446
+ // Assigned rather than set in the literal: with exactOptionalPropertyTypes a
447
+ // `body: undefined` is not the same as no body at all.
448
+ if (body !== undefined)
449
+ init.body = new Blob([new Uint8Array(body)]);
450
+ response = await fetch(url, init);
451
+ }
452
+ catch (cause) {
453
+ throw new Error(`连接 WebDAV 服务器 ${this.address} 失败:${describeCause(cause)}`);
454
+ }
455
+ if (response.status === 401) {
456
+ await drain(response);
457
+ throw new LoginError();
458
+ }
459
+ if (response.status === 429 || response.status === 503)
460
+ throw this.limited(response);
461
+ return response;
462
+ }
463
+ /** The rate-limit error for an answer, carrying the server's own wait when it named one. */
464
+ limited(response) {
465
+ return new RateLimitedError(response.status, retryAfter(response.headers.get('retry-after')));
466
+ }
467
+ }
468
+ /** The version an answer's headers name; empty when nothing can be asked about again. */
469
+ function versionOf(headers) {
470
+ const etag = headers.get('etag');
471
+ if (etag !== null && etag !== '')
472
+ return { etag };
473
+ const modified = headers.get('last-modified');
474
+ if (modified === null || modified === '')
475
+ return {};
476
+ const stamp = Date.parse(modified);
477
+ const served = Date.parse(headers.get('date') ?? '');
478
+ if (!Number.isFinite(stamp))
479
+ return {};
480
+ // Kept only when it was a second or more before the server's own clock at the
481
+ // read, so a write later in that same second cannot go unseen.
482
+ if (!Number.isFinite(served) || served - stamp < 1000)
483
+ return {};
484
+ return { modified };
485
+ }
486
+ /** A Retry-After header's wait in milliseconds: seconds, a date, or 0. */
487
+ function retryAfter(value) {
488
+ if (value === null)
489
+ return 0;
490
+ const trimmed = value.trim();
491
+ const seconds = Number(trimmed);
492
+ if (Number.isFinite(seconds) && seconds > 0)
493
+ return Math.round(seconds * 1000);
494
+ const at = Date.parse(trimmed);
495
+ if (Number.isFinite(at))
496
+ return Math.max(at - Date.now(), 0);
497
+ return 0;
498
+ }
499
+ /**
500
+ * Go's HTTP client unpacks a gzip body only when it asked for gzip itself; the
501
+ * same note applies here, where a server that compresses an `identity` read has
502
+ * to be unpacked by hand.
503
+ */
504
+ async function unpacked(data, encoding) {
505
+ const word = (encoding ?? '').trim().toLowerCase();
506
+ if (word === '' || word === 'identity')
507
+ return data;
508
+ if (word !== 'gzip' && word !== 'x-gzip' && word !== 'deflate') {
509
+ throw new Error(`WebDAV 服务器用了本插件不认的编码发送文件(${word})`);
510
+ }
511
+ const { gunzipSync, inflateSync } = await import('node:zlib');
512
+ const buffer = Buffer.from(data);
513
+ const plain = word === 'deflate' ? inflateSync(buffer) : gunzipSync(buffer);
514
+ return new Uint8Array(plain);
515
+ }
516
+ /** Read and discard a body, so the connection can be reused. */
517
+ async function drain(response) {
518
+ try {
519
+ await response.arrayBuffer();
520
+ }
521
+ catch {
522
+ // A body that cannot be read is of no use to anybody.
523
+ }
524
+ }
525
+ /** The path an address names, for messages. */
526
+ function displayPath(url) {
527
+ return url.pathname === '' || url.pathname === '/' ? '/' : url.pathname;
528
+ }
529
+ /** A fetch rejection's cause, said in one line. */
530
+ function describeCause(cause) {
531
+ if (cause instanceof Error) {
532
+ const nested = cause.cause;
533
+ if (nested instanceof Error && nested.message !== '')
534
+ return `${cause.message} (${nested.message})`;
535
+ return cause.message;
536
+ }
537
+ return String(cause);
538
+ }
539
+ /** The five XML entities a href may carry. */
540
+ function decodeXml(value) {
541
+ return value
542
+ .trim()
543
+ .replace(/&lt;/g, '<')
544
+ .replace(/&gt;/g, '>')
545
+ .replace(/&quot;/g, '"')
546
+ .replace(/&apos;/g, "'")
547
+ .replace(/&amp;/g, '&');
548
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * One sync, and the three actions that go around it.
3
+ *
4
+ * The design comes from magpie's `internal/davsync`. The setup travels in parts,
5
+ * each hashed on its own, and one sync is a three-way merge per part between what
6
+ * the server last held, what it holds now, and what is here. A part changed only
7
+ * here is pushed; one changed only there is brought in; one changed on both keeps
8
+ * the newer and keeps the other beside it under `sync/`. What the server holds is
9
+ * mirrored, so a row removed on one computer goes from the others too.
10
+ *
11
+ * The reason for each step is written where the step is, because most of them
12
+ * exist for a case that is not obvious from the code: a server that says "no file"
13
+ * with 409, a write that went out without the server checking its version, a body
14
+ * a relay cut in half.
15
+ */
16
+ import { type Bundle, type CheckResult, type ConfigView, type Part, type PartHashes, type PreviewResult, type StatusResult, type SyncConfig, type SyncResult } from './wire.js';
17
+ import { missingPlugins, type Collected, type ManifestFile, type PatchFile } from './store.js';
18
+ import { loadConfig, sameAccount, saveConfig, usable } from './state.js';
19
+ import type { ResolvedPaths } from './paths.js';
20
+ /** One read of this computer's setup, with the files it came from. */
21
+ interface Snapshot {
22
+ patch: PatchFile;
23
+ manifest: ManifestFile;
24
+ parts: Collected;
25
+ }
26
+ /** Everything a sync needs, resolved once. */
27
+ export interface Engine {
28
+ paths: ResolvedPaths;
29
+ config: SyncConfig;
30
+ }
31
+ /** The engine for a profile directory and a configuration. */
32
+ export declare function engineFor(paths: ResolvedPaths, config: SyncConfig): Engine;
33
+ /**
34
+ * Run one merge.
35
+ *
36
+ * `force` syncs even when neither side looks changed, which is what the card's
37
+ * own button does: the alternative is a button that appears to do nothing.
38
+ */
39
+ export declare function sync(engine: Engine, options?: {
40
+ force?: boolean;
41
+ }): Promise<SyncResult>;
42
+ /**
43
+ * Make this computer's setup the server's, whatever changed here.
44
+ *
45
+ * What was here first is sealed into `sync/` with the passphrase, so Undo puts it
46
+ * back. Afterwards the state claims the server already holds what is here, which
47
+ * is true: the next sync pushes it.
48
+ */
49
+ export declare function restore(engine: Engine): Promise<SyncResult>;
50
+ /**
51
+ * Write over a file on the server that is not a backup of ours.
52
+ *
53
+ * A file that opens as ours, or that was sealed with another passphrase, is never
54
+ * written over: the first is what Sync now merges with, and the second is
55
+ * somebody's backup that this computer has no business replacing. Whatever the
56
+ * server held is kept under `sync/` before anything is written.
57
+ */
58
+ export declare function upload(engine: Engine): Promise<SyncResult>;
59
+ /** Put back the setup the last restore replaced. */
60
+ export declare function undo(engine: Engine): Promise<SyncResult>;
61
+ /**
62
+ * What the card shows: the configuration without its secrets, where the files
63
+ * are, and how each part stands against the last sync.
64
+ *
65
+ * `localChanged` and `remoteChanged` are answered from the stored hashes alone,
66
+ * so opening the card costs one file read and no request to the server. A part
67
+ * the configuration does not carry is reported as it stands but never as
68
+ * changed, since nothing about it would move.
69
+ */
70
+ export declare function status(engine: Engine): Promise<StatusResult>;
71
+ /** The card's "dismiss": forget the notice, and nothing else. */
72
+ export declare function dismiss(engine: Engine): Promise<void>;
73
+ /**
74
+ * Turn sync off, keeping what is known about the server.
75
+ *
76
+ * The state is kept deliberately: turning sync back on to the same address must
77
+ * not read as joining a folder for the first time, which would take the server's
78
+ * older setup over this computer's newer one.
79
+ */
80
+ export declare function turnOff(engine: Engine): Promise<void>;
81
+ /** The configuration as the card sees it: never a secret, only whether one is set. */
82
+ export declare function viewOf(config: SyncConfig): ConfigView;
83
+ /** Reach the server and say what is there, without changing anything. */
84
+ export declare function check(engine: Engine): Promise<CheckResult>;
85
+ /** What one sync would do, decided the same way but writing nothing anywhere. */
86
+ export declare function preview(engine: Engine): Promise<PreviewResult>;
87
+ /** Read this computer's setup and the files it came from, in one pass. */
88
+ export declare function take(paths: ResolvedPaths): Promise<Snapshot>;
89
+ /** Per-part hashes of a setup; a part that is not there has none. */
90
+ export declare function hashesOf(parts: Partial<Record<Part, unknown>> | Bundle): PartHashes;
91
+ /** Whether two sets of part hashes describe the same setup. */
92
+ export declare function sameHashes(a: PartHashes, b: PartHashes | undefined): boolean;
93
+ /** When this computer last changed a part, in milliseconds, for the tie-break. */
94
+ export declare function localChangedAt(snapshot: Snapshot, part: Part): number;
95
+ /** The top-level packages present in the profile's node_modules. */
96
+ export declare function installedPackages(paths: ResolvedPaths): Promise<string[]>;
97
+ /** The profile directory a set of paths names, for a caller that only has a config. */
98
+ export declare function pathsOf(profileDir: string): ResolvedPaths;
99
+ export { loadConfig, sameAccount, saveConfig, usable, missingPlugins };
100
+ export type { PreviewResult };