dsh-webdav-sync 1.0.0 → 1.0.2
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/README.md +119 -25
- package/lib/client.js +563 -65
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +119 -0
- package/lib/relay.js +683 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/dav.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.
|
|
2
|
+
* The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.prop`,
|
|
3
3
|
* and the whole protocol between this computer and that file is:
|
|
4
4
|
*
|
|
5
5
|
* - read it, with the version last seen so an unchanged file costs one request;
|
|
@@ -18,10 +18,6 @@ import { type ServerVersion } from './wire.js';
|
|
|
18
18
|
export declare class ChangedError extends Error {
|
|
19
19
|
constructor();
|
|
20
20
|
}
|
|
21
|
-
/** The file is still the version last read; the server did not send it again. */
|
|
22
|
-
export declare class NotModifiedError extends Error {
|
|
23
|
-
constructor();
|
|
24
|
-
}
|
|
25
21
|
/** The server refused the account. */
|
|
26
22
|
export declare class LoginError extends Error {
|
|
27
23
|
constructor();
|
|
@@ -42,6 +38,14 @@ export interface DavOptions {
|
|
|
42
38
|
url: string;
|
|
43
39
|
user: string;
|
|
44
40
|
password: string;
|
|
41
|
+
/**
|
|
42
|
+
* Extra headers sent with every request, one `Name: value` pair each. They go
|
|
43
|
+
* on before this client's own headers, so a header the protocol depends on —
|
|
44
|
+
* `If-Match`, `If-None-Match`, `Depth`, `Accept-Encoding` — cannot be broken
|
|
45
|
+
* by one configured here; and they go on before the Basic credential too, so
|
|
46
|
+
* a configured `Authorization` replaces it.
|
|
47
|
+
*/
|
|
48
|
+
headers?: Record<string, string>;
|
|
45
49
|
}
|
|
46
50
|
/**
|
|
47
51
|
* What one read found. `data` is absent when the server holds no file yet — which
|
|
@@ -68,6 +72,7 @@ export declare class Dav {
|
|
|
68
72
|
private readonly basePath;
|
|
69
73
|
private readonly user;
|
|
70
74
|
private readonly password;
|
|
75
|
+
private readonly extraHeaders;
|
|
71
76
|
constructor(options: DavOptions);
|
|
72
77
|
/** The address, without any credentials that may have ridden in on it. */
|
|
73
78
|
get address(): string;
|
|
@@ -79,10 +84,18 @@ export declare class Dav {
|
|
|
79
84
|
* Read the backup.
|
|
80
85
|
*
|
|
81
86
|
* Returns `undefined` when the server holds none yet — which several servers
|
|
82
|
-
* say in several ways.
|
|
83
|
-
* current
|
|
87
|
+
* say in several ways. Returns `{ notModified: true }` when `have` is still
|
|
88
|
+
* current. Throws {@link LoginError}, {@link RateLimitedError} or
|
|
89
|
+
* {@link ForbiddenError}.
|
|
84
90
|
*/
|
|
85
91
|
get(have?: ServerVersion): Promise<DavRead>;
|
|
92
|
+
/**
|
|
93
|
+
* One read of one address: the parsed answer, or `undefined` when nothing is
|
|
94
|
+
* there — 404, 410, 409 (how 坚果云 answers a read of a folder that is not there
|
|
95
|
+
* yet), and a Synology's 403 for a file in a folder that is not there, which is
|
|
96
|
+
* the state before the first sync.
|
|
97
|
+
*/
|
|
98
|
+
private readAt;
|
|
86
99
|
/**
|
|
87
100
|
* Write the backup, only over the version that was read when there was one.
|
|
88
101
|
*
|
|
@@ -135,3 +148,10 @@ export declare class Dav {
|
|
|
135
148
|
/** The rate-limit error for an answer, carrying the server's own wait when it named one. */
|
|
136
149
|
private limited;
|
|
137
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse the configured extra headers: one `Name: value` per line, blank lines
|
|
153
|
+
* and lines starting with `#` ignored. A line that cannot be a header is
|
|
154
|
+
* refused with the line number rather than dropped, so a typo shows up while it
|
|
155
|
+
* is being saved instead of surfacing as a puzzling failure on the next sync.
|
|
156
|
+
*/
|
|
157
|
+
export declare function parseHeaders(text: string): Record<string, string>;
|
package/lib/dav.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.
|
|
2
|
+
* The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.prop`,
|
|
3
3
|
* and the whole protocol between this computer and that file is:
|
|
4
4
|
*
|
|
5
5
|
* - read it, with the version last seen so an unchanged file costs one request;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* it hands back, which then never matches in `If-Match`. Every one of those is
|
|
14
14
|
* handled where it happens, with the reason it exists written beside it.
|
|
15
15
|
*/
|
|
16
|
-
import { REMOTE_FILE, REMOTE_FOLDER } from './wire.js';
|
|
16
|
+
import { LEGACY_REMOTE_FILE, REMOTE_FILE, REMOTE_FOLDER } from './wire.js';
|
|
17
17
|
/** The file was not written because it changed on the server since it was read. */
|
|
18
18
|
export class ChangedError extends Error {
|
|
19
19
|
constructor() {
|
|
@@ -21,13 +21,6 @@ export class ChangedError extends Error {
|
|
|
21
21
|
this.name = 'ChangedError';
|
|
22
22
|
}
|
|
23
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
24
|
/** The server refused the account. */
|
|
32
25
|
export class LoginError extends Error {
|
|
33
26
|
constructor() {
|
|
@@ -73,6 +66,7 @@ export class Dav {
|
|
|
73
66
|
basePath;
|
|
74
67
|
user;
|
|
75
68
|
password;
|
|
69
|
+
extraHeaders;
|
|
76
70
|
constructor(options) {
|
|
77
71
|
const trimmed = (options.url ?? '').trim();
|
|
78
72
|
let parsed;
|
|
@@ -93,6 +87,7 @@ export class Dav {
|
|
|
93
87
|
this.basePath = path;
|
|
94
88
|
this.user = options.user ?? '';
|
|
95
89
|
this.password = options.password ?? '';
|
|
90
|
+
this.extraHeaders = options.headers ?? {};
|
|
96
91
|
}
|
|
97
92
|
/** The address, without any credentials that may have ridden in on it. */
|
|
98
93
|
get address() {
|
|
@@ -115,8 +110,9 @@ export class Dav {
|
|
|
115
110
|
* Read the backup.
|
|
116
111
|
*
|
|
117
112
|
* Returns `undefined` when the server holds none yet — which several servers
|
|
118
|
-
* say in several ways.
|
|
119
|
-
* current
|
|
113
|
+
* say in several ways. Returns `{ notModified: true }` when `have` is still
|
|
114
|
+
* current. Throws {@link LoginError}, {@link RateLimitedError} or
|
|
115
|
+
* {@link ForbiddenError}.
|
|
120
116
|
*/
|
|
121
117
|
async get(have) {
|
|
122
118
|
const headers = { 'accept-encoding': 'identity' };
|
|
@@ -127,30 +123,46 @@ export class Dav {
|
|
|
127
123
|
headers['if-none-match'] = have.etag;
|
|
128
124
|
else if (have?.modified !== undefined && have.modified !== '')
|
|
129
125
|
headers['if-modified-since'] = have.modified;
|
|
126
|
+
const read = await this.readAt(this.fileUrl, headers, have);
|
|
127
|
+
if (read !== undefined)
|
|
128
|
+
return read;
|
|
129
|
+
// A folder this plugin filled under its earlier name still holds the same
|
|
130
|
+
// backup under that name. Reading it makes this run an ordinary sync; calling
|
|
131
|
+
// the server empty instead would have this computer push its own setup up
|
|
132
|
+
// over whatever the other computer left there, silently.
|
|
133
|
+
//
|
|
134
|
+
// Its version is dropped rather than carried: the write that follows names
|
|
135
|
+
// the new file, which the server has no ETag for, and an `If-Match` against a
|
|
136
|
+
// file that does not exist yet would be refused. The merge still runs against
|
|
137
|
+
// the bytes just read, so nothing is written blind that was not merged first.
|
|
138
|
+
const legacy = await this.readAt(this.url(REMOTE_FOLDER, LEGACY_REMOTE_FILE), headers, have);
|
|
139
|
+
return legacy === undefined ? { version: {} } : { ...legacy, version: {} };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* One read of one address: the parsed answer, or `undefined` when nothing is
|
|
143
|
+
* there — 404, 410, 409 (how 坚果云 answers a read of a folder that is not there
|
|
144
|
+
* yet), and a Synology's 403 for a file in a folder that is not there, which is
|
|
145
|
+
* the state before the first sync.
|
|
146
|
+
*/
|
|
147
|
+
async readAt(url, headers, have) {
|
|
130
148
|
const conditional = headers['if-none-match'] !== undefined || headers['if-modified-since'] !== undefined;
|
|
131
|
-
const response = await this.send('GET',
|
|
149
|
+
const response = await this.send('GET', url, undefined, headers);
|
|
132
150
|
if (response.status === 304 && conditional) {
|
|
133
151
|
return { version: have ?? {}, notModified: true };
|
|
134
152
|
}
|
|
135
153
|
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
154
|
if (!(await this.there(`${this.url(REMOTE_FOLDER)}/`)))
|
|
139
|
-
return
|
|
155
|
+
return undefined;
|
|
140
156
|
throw await this.forbiddenMessage();
|
|
141
157
|
}
|
|
142
158
|
if (response.status === 404 || response.status === 410 || response.status === 409) {
|
|
143
|
-
|
|
144
|
-
return { version: {} };
|
|
159
|
+
return undefined;
|
|
145
160
|
}
|
|
146
161
|
if (response.status !== 200) {
|
|
147
162
|
await drain(response);
|
|
148
163
|
throw new Error(`从 WebDAV 服务器读取 ${REMOTE_FILE} 失败:HTTP ${response.status}`);
|
|
149
164
|
}
|
|
150
|
-
const buffer =
|
|
151
|
-
if (buffer.byteLength > MAX_BYTES) {
|
|
152
|
-
throw new Error(`WebDAV 服务器上的文件超过 ${MAX_BYTES} 字节,没有读取`);
|
|
153
|
-
}
|
|
165
|
+
const buffer = await readLimited(response, MAX_BYTES);
|
|
154
166
|
const data = await unpacked(buffer, response.headers.get('content-encoding'));
|
|
155
167
|
// A redirect that ended somewhere other than the file — a web app's sign-in
|
|
156
168
|
// or files page — is carried out with where it went, so the caller can say
|
|
@@ -273,7 +285,12 @@ export class Dav {
|
|
|
273
285
|
// 405: the server does not do HEAD, so the size is asked of PROPFIND instead.
|
|
274
286
|
// 403/404/409/410: no file yet, which is not an error here.
|
|
275
287
|
const found = await this.length(this.fileUrl);
|
|
276
|
-
|
|
288
|
+
if (found !== undefined)
|
|
289
|
+
return { exists: true, size: found };
|
|
290
|
+
// A backup still under the earlier name is one this plugin wrote, and the
|
|
291
|
+
// question the card asks — "is there one on the server" — means that one too.
|
|
292
|
+
const legacy = await this.length(this.url(REMOTE_FOLDER, LEGACY_REMOTE_FILE));
|
|
293
|
+
return legacy === undefined ? { exists: false } : { exists: true, size: legacy };
|
|
277
294
|
}
|
|
278
295
|
/**
|
|
279
296
|
* Make the folder, tolerating the several ways servers say it is already
|
|
@@ -430,9 +447,18 @@ export class Dav {
|
|
|
430
447
|
/** One request, with auth, redirect and rate-limit handling applied. */
|
|
431
448
|
async send(method, url, body, headers = {}) {
|
|
432
449
|
const requestHeaders = new Headers();
|
|
450
|
+
// Configured headers go on first, so a header this client computes per
|
|
451
|
+
// request wins a name collision: a configured `If-Match` would turn the
|
|
452
|
+
// conditional write into a blind one, and a configured `Depth` would make
|
|
453
|
+
// the folder probe answer for the wrong level.
|
|
454
|
+
for (const [key, value] of Object.entries(this.extraHeaders))
|
|
455
|
+
requestHeaders.set(key, value);
|
|
433
456
|
for (const [key, value] of Object.entries(headers))
|
|
434
457
|
requestHeaders.set(key, value);
|
|
435
|
-
|
|
458
|
+
// The credential is the fallback, not the winner: a configured
|
|
459
|
+
// `Authorization` (a bearer token for a proxy in front of the server, say)
|
|
460
|
+
// is left in place.
|
|
461
|
+
if ((this.user !== '' || this.password !== '') && !requestHeaders.has('authorization')) {
|
|
436
462
|
requestHeaders.set('authorization', `Basic ${Buffer.from(`${this.user}:${this.password}`, 'utf8').toString('base64')}`);
|
|
437
463
|
}
|
|
438
464
|
let response;
|
|
@@ -465,6 +491,28 @@ export class Dav {
|
|
|
465
491
|
return new RateLimitedError(response.status, retryAfter(response.headers.get('retry-after')));
|
|
466
492
|
}
|
|
467
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Parse the configured extra headers: one `Name: value` per line, blank lines
|
|
496
|
+
* and lines starting with `#` ignored. A line that cannot be a header is
|
|
497
|
+
* refused with the line number rather than dropped, so a typo shows up while it
|
|
498
|
+
* is being saved instead of surfacing as a puzzling failure on the next sync.
|
|
499
|
+
*/
|
|
500
|
+
export function parseHeaders(text) {
|
|
501
|
+
const out = {};
|
|
502
|
+
const lines = (text ?? '').split(/\r?\n/);
|
|
503
|
+
lines.forEach((raw, index) => {
|
|
504
|
+
const line = raw.trim();
|
|
505
|
+
if (line === '' || line.startsWith('#'))
|
|
506
|
+
return;
|
|
507
|
+
const at = line.indexOf(':');
|
|
508
|
+
const name = at < 0 ? '' : line.slice(0, at).trim();
|
|
509
|
+
if (at < 1 || !/^[\w!#$%&'*+\-.^`|~]+$/i.test(name)) {
|
|
510
|
+
throw new Error(`第 ${index + 1} 行「${line}」不是请求头:请写成「名字: 值」,一行一个`);
|
|
511
|
+
}
|
|
512
|
+
out[name] = line.slice(at + 1).trim();
|
|
513
|
+
});
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
468
516
|
/** The version an answer's headers name; empty when nothing can be asked about again. */
|
|
469
517
|
function versionOf(headers) {
|
|
470
518
|
const etag = headers.get('etag');
|
|
@@ -510,17 +558,75 @@ async function unpacked(data, encoding) {
|
|
|
510
558
|
}
|
|
511
559
|
const { gunzipSync, inflateSync } = await import('node:zlib');
|
|
512
560
|
const buffer = Buffer.from(data);
|
|
513
|
-
|
|
514
|
-
|
|
561
|
+
// The cap is on what the read accepts, so it applies to the bytes that come out
|
|
562
|
+
// of here too: a small compressed body that unpacks to gigabytes would otherwise
|
|
563
|
+
// be the one way left to make this process hold more than it agreed to.
|
|
564
|
+
try {
|
|
565
|
+
const plain = (word === 'deflate' ? inflateSync : gunzipSync)(buffer, { maxOutputLength: MAX_BYTES });
|
|
566
|
+
return new Uint8Array(plain);
|
|
567
|
+
}
|
|
568
|
+
catch (cause) {
|
|
569
|
+
if (cause.code === 'ERR_BUFFER_TOO_LARGE') {
|
|
570
|
+
throw new Error(`WebDAV 服务器上的文件解压后超过 ${MAX_BYTES} 字节,没有读取`);
|
|
571
|
+
}
|
|
572
|
+
throw cause;
|
|
573
|
+
}
|
|
515
574
|
}
|
|
516
|
-
/**
|
|
575
|
+
/**
|
|
576
|
+
* Read and discard a body, so the connection can be reused.
|
|
577
|
+
*
|
|
578
|
+
* Only the first slice of it, and only up to a bound: an error page this plugin
|
|
579
|
+
* is about to throw away is not worth holding a whole answer for, and a server
|
|
580
|
+
* that answers with something enormous must not be able to make it.
|
|
581
|
+
*/
|
|
517
582
|
async function drain(response) {
|
|
518
583
|
try {
|
|
519
|
-
await response
|
|
584
|
+
await readLimited(response, MAX_BYTES);
|
|
520
585
|
}
|
|
521
586
|
catch {
|
|
522
|
-
// A body that cannot be read is of no use to anybody
|
|
587
|
+
// A body that cannot be read is of no use to anybody, and neither is its
|
|
588
|
+
// size: the caller is already on the way to reporting the status.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Read a body, refusing it the moment it grows past `limit`.
|
|
593
|
+
*
|
|
594
|
+
* The count is taken as the bytes arrive rather than after the fact: a server
|
|
595
|
+
* that sends no length, or sends a length that is not true, would otherwise have
|
|
596
|
+
* this process hold the whole answer in memory before the check that rejects it
|
|
597
|
+
* gets to run.
|
|
598
|
+
*/
|
|
599
|
+
async function readLimited(response, limit) {
|
|
600
|
+
const body = response.body;
|
|
601
|
+
if (body === null) {
|
|
602
|
+
const whole = new Uint8Array(await response.arrayBuffer());
|
|
603
|
+
if (whole.byteLength > limit)
|
|
604
|
+
throw new Error(`WebDAV 服务器上的文件超过 ${limit} 字节,没有读取`);
|
|
605
|
+
return whole;
|
|
606
|
+
}
|
|
607
|
+
const reader = body.getReader();
|
|
608
|
+
const chunks = [];
|
|
609
|
+
let total = 0;
|
|
610
|
+
for (;;) {
|
|
611
|
+
const { done, value } = await reader.read();
|
|
612
|
+
if (done)
|
|
613
|
+
break;
|
|
614
|
+
if (value === undefined)
|
|
615
|
+
continue;
|
|
616
|
+
total += value.byteLength;
|
|
617
|
+
if (total > limit) {
|
|
618
|
+
await reader.cancel().catch(() => undefined);
|
|
619
|
+
throw new Error(`WebDAV 服务器上的文件超过 ${limit} 字节,没有读取`);
|
|
620
|
+
}
|
|
621
|
+
chunks.push(value);
|
|
622
|
+
}
|
|
623
|
+
const out = new Uint8Array(total);
|
|
624
|
+
let at = 0;
|
|
625
|
+
for (const chunk of chunks) {
|
|
626
|
+
out.set(chunk, at);
|
|
627
|
+
at += chunk.byteLength;
|
|
523
628
|
}
|
|
629
|
+
return out;
|
|
524
630
|
}
|
|
525
631
|
/** The path an address names, for messages. */
|
|
526
632
|
function displayPath(url) {
|
package/lib/dirs.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extra directories: reading them into the `dirs` part, and writing that
|
|
3
|
+
* part back out.
|
|
4
|
+
*
|
|
5
|
+
* The four profile parts are read from two files whose shape this plugin already
|
|
6
|
+
* knows. This one is different in three ways, and each of them shapes the code
|
|
7
|
+
* below:
|
|
8
|
+
*
|
|
9
|
+
* - **It is a whole tree, not a file.** Reading means walking, so it is capped:
|
|
10
|
+
* a directory over the cap is refused as a whole rather than half-carried,
|
|
11
|
+
* because half a directory that then gets pushed would delete the rest for
|
|
12
|
+
* everybody else.
|
|
13
|
+
* - **Its path is part of the data.** The user names a directory; the path
|
|
14
|
+
* travels so a restore writes back into the same directory instead of inventing
|
|
15
|
+
* one. A path this computer cannot make sense of is therefore not an error to
|
|
16
|
+
* shout about — it is a directory to leave exactly as the server has it.
|
|
17
|
+
* - **Writing is additive.** A sync writes and overwrites files here; it never
|
|
18
|
+
* removes one. Mirrored deletion inside a person's own directory is the one
|
|
19
|
+
* accident this feature could cause that no notice could undo, and a union is
|
|
20
|
+
* the safe half of the trade: a file deleted on one computer comes back from the
|
|
21
|
+
* others, and deleting it everywhere is done by hand — the same bargain the
|
|
22
|
+
* plugin already makes for the plugin list (see
|
|
23
|
+
* `docs/decisions/0005-four-parts-and-plugin-exception.md`).
|
|
24
|
+
*/
|
|
25
|
+
import type { DirProblem, DirSpec, DirsPart } from './wire.js';
|
|
26
|
+
/**
|
|
27
|
+
* Names never descended into. `node_modules` because a plugin's own dependencies
|
|
28
|
+
* are installed on each computer rather than carried, `.git` because a repository
|
|
29
|
+
* there has an owner of its own. Both are skipped in silence: a skipped name is
|
|
30
|
+
* not a directory that failed to read.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DIRS_SKIP: ReadonlySet<string>;
|
|
33
|
+
/** Most files one configured directory may carry. */
|
|
34
|
+
export declare const DIRS_MAX_FILES = 2000;
|
|
35
|
+
/** Most bytes one configured directory may carry, before base64 and sealing. */
|
|
36
|
+
export declare const DIRS_MAX_BYTES: number;
|
|
37
|
+
/** What reading the configured directories produced. */
|
|
38
|
+
export interface DirsRead {
|
|
39
|
+
/** The directories that could be read, in the configured order. */
|
|
40
|
+
entries: DirSpec[];
|
|
41
|
+
/** Newest modification time among every file read; 0 when nothing was read. */
|
|
42
|
+
mtimeMs: number;
|
|
43
|
+
/** Directories that could not be read, and why. */
|
|
44
|
+
problems: DirProblem[];
|
|
45
|
+
}
|
|
46
|
+
/** Writing reported back to the caller, for the run's own bookkeeping. */
|
|
47
|
+
export interface DirsWrite {
|
|
48
|
+
/** True when at least one file's bytes were written. */
|
|
49
|
+
changed: boolean;
|
|
50
|
+
/** The absolute paths the part carried, in order, for the configuration to record. */
|
|
51
|
+
paths: string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The stored list of directories: strings only, trimmed, no blanks, no repeats,
|
|
55
|
+
* in a fixed order.
|
|
56
|
+
*
|
|
57
|
+
* The order is by path rather than by when each was added, so the same set of
|
|
58
|
+
* directories hashes the same on every computer — the part is hashed whole, and
|
|
59
|
+
* two computers that name the same directories in a different order would
|
|
60
|
+
* otherwise see a change that is not one.
|
|
61
|
+
*/
|
|
62
|
+
export declare function normaliseDirs(value: unknown): string[];
|
|
63
|
+
/** Path order, used wherever the set of directories is put in canonical order. */
|
|
64
|
+
export declare function comparePath(a: string, b: string): number;
|
|
65
|
+
/**
|
|
66
|
+
* Read every configured directory.
|
|
67
|
+
*
|
|
68
|
+
* Nothing throws: this runs on every status call, and a card that cannot be
|
|
69
|
+
* opened because a directory on a disconnected drive is not there would be worse
|
|
70
|
+
* than a card that says so. What cannot be read is reported in `problems`, and
|
|
71
|
+
* {@link dirsPartOf} decides what to do about it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function readDirs(dirs: readonly string[]): Promise<DirsRead>;
|
|
74
|
+
/**
|
|
75
|
+
* The `dirs` part this computer holds, given what the server holds.
|
|
76
|
+
*
|
|
77
|
+
* Without a problem this is simply what is on disk — and `undefined` when there is
|
|
78
|
+
* nothing on disk, which keeps a computer that syncs no directories from putting an
|
|
79
|
+
* empty `dirs` part on the server and quietly emptying it for the computers that do.
|
|
80
|
+
*
|
|
81
|
+
* With a problem it is still what is on disk, except for the directories that could
|
|
82
|
+
* not be read: those take the server's copy verbatim. That is the whole reason this
|
|
83
|
+
* function exists. Reading a directory that is not there as "a directory with no
|
|
84
|
+
* files in it" would make the next push announce that every file in it was deleted,
|
|
85
|
+
* and a disconnected drive would quietly empty everybody else's copy of it. Carrying
|
|
86
|
+
* the server's copy instead means such a directory is neither read nor written this
|
|
87
|
+
* run, and the card says which one it was.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dirsPartOf(read: DirsRead, carried: DirsPart | undefined): DirsPart | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Put one `dirs` part on disk.
|
|
92
|
+
*
|
|
93
|
+
* Only directories that are absolute *on this computer* are touched: the list
|
|
94
|
+
* travels, so a path another computer named may not be a path here, and writing
|
|
95
|
+
* into a made-up relative one would scatter files around the working directory.
|
|
96
|
+
* Those paths stay in the list — the part is carried whole, and dropping one here
|
|
97
|
+
* would delete it for the computers that can use it — and are simply not read or
|
|
98
|
+
* written on this machine.
|
|
99
|
+
*
|
|
100
|
+
* `skip` holds the directories this run could not read. They are left alone for
|
|
101
|
+
* the same reason {@link dirsPartOf} carries them: whatever is at that path now is
|
|
102
|
+
* not this sync's business to replace, and it may well be nothing at all.
|
|
103
|
+
*/
|
|
104
|
+
export declare function writeDirs(part: DirsPart, skip: ReadonlySet<string>): Promise<DirsWrite>;
|
|
105
|
+
/**
|
|
106
|
+
* Whether writing the part would change anything on disk.
|
|
107
|
+
*
|
|
108
|
+
* Asked before the copy kept for Undo is taken. That copy carries the directories
|
|
109
|
+
* when they are about to be written to, and a tree can run to megabytes — sealing one
|
|
110
|
+
* on every sync whose server copy of this part merely *existed* would cost a
|
|
111
|
+
* profile-size file per sync for a write that changes no file, which is exactly what
|
|
112
|
+
* happens on a computer that syncs no directories of its own and is sent somebody's.
|
|
113
|
+
*/
|
|
114
|
+
export declare function dirsDiffer(part: DirsPart, skip: ReadonlySet<string>): Promise<boolean>;
|