@wirecat/cli-core 0.385.0 → 0.387.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/CHANGELOG.md +24 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/documents/index.d.ts +5 -4
- package/dist/documents/index.js +3 -2
- package/dist/documents/markdown-blocks.d.ts +12 -0
- package/dist/documents/markdown-blocks.js +29 -17
- package/dist/documents/push-model.d.ts +30 -4
- package/dist/services/documents/block-diff.d.ts +18 -9
- package/dist/services/documents/block-diff.js +99 -24
- package/dist/services/documents/markdown-splice.d.ts +15 -0
- package/dist/services/documents/markdown-splice.js +206 -0
- package/dist/services/documents/push.d.ts +17 -7
- package/dist/services/documents/push.js +67 -13
- package/dist/store/sqlite/emails.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.387.0 — 22.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `dateWindow` and `DateWindow` from `./cli`: `--since-time` and `--until-time` read together, with the
|
|
15
|
+
`meta.timezone` and `meta.window` an answer reports them by (WC-TIME-3, WC-TIME-5).
|
|
16
|
+
|
|
17
|
+
## 0.386.0 — 22.10.2026
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Document sync works both ways.** `pushDocuments` reads every mirrored file back on each run and merges changes
|
|
22
|
+
made in the provider into the local file. Only the lines of changed blocks are rewritten, and a tick is flipped in
|
|
23
|
+
place. A conflict lists the changed parts and when each side changed, and the run goes on with the other files;
|
|
24
|
+
`keep: "local" | "remote" | "both"` settles conflicts. New: `mergeBlocks`, `spliceMarkdown`, `blocksMarkdown`,
|
|
25
|
+
`inlineMarkdown`, `placedMarkdownBlocks`.
|
|
26
|
+
|
|
27
|
+
### Changed — may break callers
|
|
28
|
+
|
|
29
|
+
- `PushOptions.force` is replaced by `keep` (settle conflicts) and `recreate` (create a remotely deleted file again);
|
|
30
|
+
`prepare` gets `keep` instead of `force`. Its update result is `{ local?, afterLocal?, remote, write }`:
|
|
31
|
+
`remote: false` writes only the local file; `afterLocal` is saved between the local and the remote write. `PushResult` adds `pulled`; a conflict may carry `parts`, `localChangedAt`,
|
|
32
|
+
`remoteChangedAt` and `remoteChangedBy`. `reconcileBlocks` is removed; `mergeBlocks` replaces it.
|
|
33
|
+
|
|
10
34
|
## 0.385.0 — 22.10.2026
|
|
11
35
|
|
|
12
36
|
### Fixed
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { guardedApp, guardedPress, guardedStart, type Pressed } from "../sends/b
|
|
|
27
27
|
export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
|
|
28
28
|
export type { InboxReader } from "../services/inbox.js";
|
|
29
29
|
export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
|
|
30
|
-
export { momentOf } from "../services/moment.js";
|
|
30
|
+
export { type DateWindow, dateWindow, momentOf } from "../services/moment.js";
|
|
31
31
|
export { maskedAccount } from "../services/people.js";
|
|
32
32
|
export { unanswered } from "../services/questions.js";
|
|
33
33
|
export { openRequestTask } from "../services/task-rules.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { currentRun, inRun, useAsProcessRun } from "../runs/scope.js";
|
|
|
22
22
|
export { guardedApp, guardedPress, guardedStart } from "../sends/buttons.js";
|
|
23
23
|
export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
|
|
24
24
|
export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
|
|
25
|
-
export { momentOf } from "../services/moment.js";
|
|
25
|
+
export { dateWindow, momentOf } from "../services/moment.js";
|
|
26
26
|
export { maskedAccount } from "../services/people.js";
|
|
27
27
|
export { unanswered } from "../services/questions.js";
|
|
28
28
|
export { openRequestTask } from "../services/task-rules.js";
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
export { type BlockHunk, diffBlocks, type
|
|
1
|
+
export { type BlockConflict, type BlockHunk, diffBlocks, type Merged, mergeBlocks, } from "../services/documents/block-diff.js";
|
|
2
2
|
export { type Complete, extractMemories } from "../services/documents/extraction.js";
|
|
3
3
|
export { documentIngestion, type IngestionOptions } from "../services/documents/ingestion.js";
|
|
4
4
|
export { type DocumentKnowledge, documentKnowledge } from "../services/documents/knowledge.js";
|
|
5
|
+
export { blocksMarkdown, inlineMarkdown, spliceMarkdown } from "../services/documents/markdown-splice.js";
|
|
5
6
|
export type { SyncOptions } from "../services/documents/ports.js";
|
|
6
7
|
export { extractDocumentMemories, type ImportedSource, type ProposalOptions } from "../services/documents/proposals.js";
|
|
7
|
-
export { type PushOptions, type PushResult, planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
|
|
8
|
+
export { type PushConflict, type PushOptions, type PushResult, planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
|
|
8
9
|
export { type SyncResult, syncDocuments } from "../services/documents/sync.js";
|
|
9
10
|
export { type Block, blockKey, blockShape, type Inline, type ListKind, normalizeInlines } from "./blocks.js";
|
|
10
11
|
export { catalogFiles } from "./catalog.js";
|
|
11
|
-
export { markdownBlocks } from "./markdown-blocks.js";
|
|
12
|
+
export { markdownBlocks, type PlacedBlock, placedMarkdownBlocks } from "./markdown-blocks.js";
|
|
12
13
|
export type { DocumentSource, IndexedSource, PictureWriter, SourceChange, SourceContent, SourceFile, SourceIndex, SourceSkip, SourceState, SourceStatePort, SourceWalk, SourceWork, TabText, } from "./model.js";
|
|
13
|
-
export type { DocumentTarget, LocalDocument, Prepared, PushEntry, Pushed, PushPlan, PushState, PushStatePort, } from "./push-model.js";
|
|
14
|
+
export type { ConflictPart, DocumentTarget, Keep, LocalDocument, Prepared, PushEntry, Pushed, PushPlan, PushState, PushStatePort, RemoteChange, } from "./push-model.js";
|
|
14
15
|
export { within } from "./scope.js";
|
package/dist/documents/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
export { diffBlocks,
|
|
1
|
+
export { diffBlocks, mergeBlocks, } from "../services/documents/block-diff.js";
|
|
2
2
|
export { extractMemories } from "../services/documents/extraction.js";
|
|
3
3
|
export { documentIngestion } from "../services/documents/ingestion.js";
|
|
4
4
|
export { documentKnowledge } from "../services/documents/knowledge.js";
|
|
5
|
+
export { blocksMarkdown, inlineMarkdown, spliceMarkdown } from "../services/documents/markdown-splice.js";
|
|
5
6
|
export { extractDocumentMemories } from "../services/documents/proposals.js";
|
|
6
7
|
export { planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
|
|
7
8
|
export { syncDocuments } from "../services/documents/sync.js";
|
|
8
9
|
export { blockKey, blockShape, normalizeInlines } from "./blocks.js";
|
|
9
10
|
export { catalogFiles } from "./catalog.js";
|
|
10
|
-
export { markdownBlocks } from "./markdown-blocks.js";
|
|
11
|
+
export { markdownBlocks, placedMarkdownBlocks } from "./markdown-blocks.js";
|
|
11
12
|
export { within } from "./scope.js";
|
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import { type Block } from "./blocks.js";
|
|
2
|
+
/**
|
|
3
|
+
* A block and the source it came from: `[start, end)` offsets into the Markdown. Blocks cut from one source
|
|
4
|
+
* stretch (a paragraph and its images, the paragraphs of one quote) share it. `column` is where a list item's
|
|
5
|
+
* text starts, which is how far a nested item under it must be indented.
|
|
6
|
+
*/
|
|
7
|
+
export interface PlacedBlock {
|
|
8
|
+
block: Block;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
column?: number;
|
|
12
|
+
}
|
|
2
13
|
/** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
|
|
3
14
|
export declare const markdownBlocks: (markdown: string) => Promise<Block[]>;
|
|
15
|
+
export declare const placedMarkdownBlocks: (markdown: string) => Promise<PlacedBlock[]>;
|
|
@@ -7,7 +7,8 @@ const loadParser = () => (parser ??= Promise.all([
|
|
|
7
7
|
import("micromark-extension-gfm"),
|
|
8
8
|
]).then(([{ fromMarkdown }, { gfmFromMarkdown }, { gfm }]) => (markdown) => fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })));
|
|
9
9
|
/** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
|
|
10
|
-
export const markdownBlocks = async (markdown) =>
|
|
10
|
+
export const markdownBlocks = async (markdown) => (await placedMarkdownBlocks(markdown)).map((placed) => placed.block);
|
|
11
|
+
export const placedMarkdownBlocks = async (markdown) => {
|
|
11
12
|
const tree = (await loadParser())(markdown);
|
|
12
13
|
const definitions = new Map();
|
|
13
14
|
const collect = (node) => {
|
|
@@ -64,30 +65,33 @@ export const markdownBlocks = async (markdown) => {
|
|
|
64
65
|
last.text = last.text.trimEnd();
|
|
65
66
|
return normalizeInlines(out);
|
|
66
67
|
};
|
|
67
|
-
const
|
|
68
|
+
const spanOf = (node) => ({ start: node.position?.start.offset ?? 0, end: node.position?.end.offset ?? 0 });
|
|
69
|
+
const withImages = (block, span) => {
|
|
68
70
|
const found = images.splice(0);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return block ? [block, ...found] : found;
|
|
71
|
+
const blocks = block?.kind === "paragraph" && !block.inlines.length ? found : block ? [block, ...found] : found;
|
|
72
|
+
return blocks.map((one) => ({ block: one, ...span }));
|
|
72
73
|
};
|
|
73
74
|
const textOf = (node) => node.children.flatMap((child) => child.type === "paragraph" || child.type === "heading" ? inlinesOf(child.children) : []);
|
|
74
75
|
const blocks = (nodes, depth = 0) => nodes.flatMap((node) => {
|
|
76
|
+
const span = spanOf(node);
|
|
75
77
|
switch (node.type) {
|
|
76
78
|
case "heading":
|
|
77
|
-
return withImages({ kind: "heading", level: node.depth, inlines: trimmed(inlinesOf(node.children)) });
|
|
79
|
+
return withImages({ kind: "heading", level: node.depth, inlines: trimmed(inlinesOf(node.children)) }, span);
|
|
78
80
|
case "paragraph":
|
|
79
|
-
return withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(node.children)) });
|
|
81
|
+
return withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(node.children)) }, span);
|
|
80
82
|
case "code":
|
|
81
|
-
return [{ kind: "code", ...(node.lang ? { language: node.lang } : {}), text: node.value }];
|
|
83
|
+
return [{ block: { kind: "code", ...(node.lang ? { language: node.lang } : {}), text: node.value }, ...span }];
|
|
82
84
|
case "blockquote":
|
|
83
|
-
return node.children
|
|
84
|
-
|
|
85
|
-
:
|
|
85
|
+
return node.children
|
|
86
|
+
.flatMap((child) => child.type === "paragraph" || child.type === "heading"
|
|
87
|
+
? withImages({ kind: "quote", inlines: trimmed(inlinesOf(child.children)) }, span)
|
|
88
|
+
: blocks([child], depth))
|
|
89
|
+
.map((placed) => ({ ...placed, ...span }));
|
|
86
90
|
case "thematicBreak":
|
|
87
|
-
return [{ kind: "rule" }];
|
|
91
|
+
return [{ block: { kind: "rule" }, ...span }];
|
|
88
92
|
case "table": {
|
|
89
93
|
const [head, ...rows] = node.children.map((row) => row.children.map((cell) => trimmed(inlinesOf(cell.children))));
|
|
90
|
-
return [{ kind: "table", header: head ?? [], rows }];
|
|
94
|
+
return [{ block: { kind: "table", header: head ?? [], rows }, ...span }];
|
|
91
95
|
}
|
|
92
96
|
case "list":
|
|
93
97
|
return node.children.flatMap((item) => {
|
|
@@ -100,23 +104,31 @@ export const markdownBlocks = async (markdown) => {
|
|
|
100
104
|
inlines: trimmed(first && first.type === "paragraph" ? inlinesOf(first.children) : []),
|
|
101
105
|
};
|
|
102
106
|
const tail = first && first.type !== "paragraph" ? [first, ...rest] : rest;
|
|
107
|
+
const own = {
|
|
108
|
+
start: spanOf(item).start,
|
|
109
|
+
end: first?.type === "paragraph" ? spanOf(first).end : spanOf(item).start,
|
|
110
|
+
};
|
|
111
|
+
const marker = /^([-+*]|\d{1,9}[.)])( {1,4}|(?=\n)|$)/.exec(markdown.slice(own.start))?.[0].length ?? 2;
|
|
112
|
+
const column = (item.position?.start.column ?? 1) - 1 + marker;
|
|
103
113
|
return [
|
|
104
|
-
...withImages(head),
|
|
114
|
+
...withImages(head, own).map((placed) => ({ ...placed, column })),
|
|
105
115
|
...tail.flatMap((child) => child.type === "list"
|
|
106
116
|
? blocks([child], depth + 1)
|
|
107
117
|
: child.type === "paragraph"
|
|
108
|
-
? withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(child.children)) })
|
|
118
|
+
? withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(child.children)) }, spanOf(child))
|
|
109
119
|
: blocks([child], depth)),
|
|
110
120
|
];
|
|
111
121
|
});
|
|
112
122
|
case "html":
|
|
113
|
-
return [{ kind: "paragraph", inlines: [{ text: node.value }] }];
|
|
123
|
+
return [{ block: { kind: "paragraph", inlines: [{ text: node.value }] }, ...span }];
|
|
114
124
|
case "definition":
|
|
115
125
|
case "footnoteDefinition":
|
|
116
126
|
case "yaml":
|
|
117
127
|
return [];
|
|
118
128
|
default:
|
|
119
|
-
return "children" in node
|
|
129
|
+
return "children" in node
|
|
130
|
+
? withImages({ kind: "paragraph", inlines: trimmed(textOf(node)) }, span)
|
|
131
|
+
: [];
|
|
120
132
|
}
|
|
121
133
|
});
|
|
122
134
|
return blocks(tree.children);
|
|
@@ -11,17 +11,40 @@ export interface Pushed<S> {
|
|
|
11
11
|
/** What the provider reads back after its own write; the next push compares the remote against it. */
|
|
12
12
|
snapshot: S;
|
|
13
13
|
}
|
|
14
|
+
/** Which side a stretch both sides changed keeps: the local file, the remote, or both one after the other. */
|
|
15
|
+
export type Keep = "local" | "remote" | "both";
|
|
16
|
+
/** One stretch both sides changed, as text a person can compare. */
|
|
17
|
+
export interface ConflictPart {
|
|
18
|
+
base: string;
|
|
19
|
+
local: string;
|
|
20
|
+
remote: string;
|
|
21
|
+
}
|
|
22
|
+
/** The remote's last change, when the provider can tell. */
|
|
23
|
+
export interface RemoteChange {
|
|
24
|
+
changedAt?: string;
|
|
25
|
+
changedBy?: string;
|
|
26
|
+
}
|
|
14
27
|
/**
|
|
15
|
-
* What
|
|
16
|
-
*
|
|
28
|
+
* What a sync found before writing. `local` is the file's new content when the remote changed it; `write` runs
|
|
29
|
+
* after it and reaches the remote only when `remote` is true, so a conflict or a file with nothing to change
|
|
30
|
+
* never counts as a write.
|
|
17
31
|
*/
|
|
18
32
|
export type Prepared<S> = {
|
|
19
33
|
conflict: string;
|
|
34
|
+
parts?: ConflictPart[];
|
|
35
|
+
remote?: RemoteChange;
|
|
20
36
|
} | {
|
|
21
37
|
missing: true;
|
|
22
38
|
} | {
|
|
23
39
|
unchanged: Pushed<S>;
|
|
24
40
|
} | {
|
|
41
|
+
local?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Recorded once `local` is written and before the remote write: both sides as the remote is now. If the
|
|
44
|
+
* remote write then fails, the next run sends the file's changes against it and settles nothing twice.
|
|
45
|
+
*/
|
|
46
|
+
afterLocal?: S;
|
|
47
|
+
remote: boolean;
|
|
25
48
|
write(): Promise<Pushed<S>>;
|
|
26
49
|
};
|
|
27
50
|
/**
|
|
@@ -47,12 +70,15 @@ export interface DocumentTarget<S> {
|
|
|
47
70
|
operationId: string;
|
|
48
71
|
signal?: AbortSignal;
|
|
49
72
|
}): Promise<Pushed<S>>;
|
|
50
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Reads the remote and merges it with the local file against `recorded`, what the last sync left on both sides.
|
|
75
|
+
* A stretch both sides changed is a conflict unless `keep` says which side wins.
|
|
76
|
+
*/
|
|
51
77
|
prepare(input: {
|
|
52
78
|
id: string;
|
|
53
79
|
file: LocalDocument;
|
|
54
80
|
recorded: S;
|
|
55
|
-
|
|
81
|
+
keep?: Keep;
|
|
56
82
|
signal?: AbortSignal;
|
|
57
83
|
}): Promise<Prepared<S>>;
|
|
58
84
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Block } from "../../documents/blocks.js";
|
|
2
|
+
import type { Keep } from "../../documents/push-model.js";
|
|
2
3
|
/** `base[from, to)` is replaced by `blocks`; an empty range is an insert, an empty `blocks` a delete. */
|
|
3
4
|
export interface BlockHunk {
|
|
4
5
|
from: number;
|
|
@@ -6,19 +7,27 @@ export interface BlockHunk {
|
|
|
6
7
|
blocks: Block[];
|
|
7
8
|
}
|
|
8
9
|
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
9
|
-
export declare const diffBlocks: (base: Block[], next: Block[]) => BlockHunk[];
|
|
10
|
-
|
|
10
|
+
export declare const diffBlocks: (base: Block[], next: Block[], keyOf?: (block: Block) => string) => BlockHunk[];
|
|
11
|
+
/** A stretch both sides changed differently: the blocks it held at the last sync and on each side now. */
|
|
12
|
+
export interface BlockConflict {
|
|
13
|
+
base: Block[];
|
|
14
|
+
local: Block[];
|
|
15
|
+
remote: Block[];
|
|
16
|
+
}
|
|
17
|
+
export type Merged = {
|
|
11
18
|
status: "clean";
|
|
12
19
|
blocks: Block[];
|
|
13
|
-
|
|
20
|
+
conflicts: BlockConflict[];
|
|
14
21
|
} | {
|
|
15
22
|
status: "conflict";
|
|
23
|
+
conflicts: BlockConflict[];
|
|
24
|
+
} | {
|
|
25
|
+
status: "unaligned";
|
|
16
26
|
};
|
|
17
27
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
28
|
+
* Three-way merge of a document changed on both sides since the last sync. `baseLocal` and `baseRemote` are each
|
|
29
|
+
* side as the last sync left it; they hold the same blocks, though a tick may differ. Blocks line up by their
|
|
30
|
+
* shape, so a checklist tick never collides with a text edit: each tick follows the side that changed it, the
|
|
31
|
+
* remote when neither did. A stretch both sides changed differently is a conflict, settled by `keep` when given.
|
|
23
32
|
*/
|
|
24
|
-
export declare const
|
|
33
|
+
export declare const mergeBlocks: (baseLocal: Block[], baseRemote: Block[], local: Block[], remote: Block[], keep?: Keep) => Merged;
|
|
@@ -48,11 +48,11 @@ const matches = (a, b, keyOf) => {
|
|
|
48
48
|
return pairs;
|
|
49
49
|
};
|
|
50
50
|
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
51
|
-
export const diffBlocks = (base, next) => {
|
|
51
|
+
export const diffBlocks = (base, next, keyOf = blockKey) => {
|
|
52
52
|
const hunks = [];
|
|
53
53
|
let a = 0;
|
|
54
54
|
let b = 0;
|
|
55
|
-
for (const [i, j] of [...matches(base, next,
|
|
55
|
+
for (const [i, j] of [...matches(base, next, keyOf), [base.length, next.length]]) {
|
|
56
56
|
if (i > a || j > b)
|
|
57
57
|
hunks.push({ from: a, to: i, blocks: next.slice(b, j) });
|
|
58
58
|
a = i + 1;
|
|
@@ -61,31 +61,106 @@ export const diffBlocks = (base, next) => {
|
|
|
61
61
|
return hunks;
|
|
62
62
|
};
|
|
63
63
|
const isCheck = (block) => block?.kind === "item" && block.list === "check";
|
|
64
|
+
const sameKeys = (a, b) => a.length === b.length && a.every((block, index) => blockKey(block) === blockKey(b[index]));
|
|
65
|
+
/** Two hunks touch the same stretch: their ranges overlap, both insert at one point, or one inserts inside the other. */
|
|
66
|
+
const touches = (a, b) => Math.max(a.from, b.from) < Math.min(a.to, b.to) ||
|
|
67
|
+
a.from === b.from ||
|
|
68
|
+
(a.from === a.to && b.from < a.from && a.from < b.to) ||
|
|
69
|
+
(b.from === b.to && a.from < b.from && b.from < a.to);
|
|
70
|
+
const applied = (base, from, to, hunks) => {
|
|
71
|
+
let out = [];
|
|
72
|
+
let at = from;
|
|
73
|
+
for (const hunk of hunks) {
|
|
74
|
+
out = out.concat(base.slice(at, hunk.from), hunk.blocks);
|
|
75
|
+
at = hunk.to;
|
|
76
|
+
}
|
|
77
|
+
return out.concat(base.slice(at, to));
|
|
78
|
+
};
|
|
64
79
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
80
|
+
* Three-way merge of a document changed on both sides since the last sync. `baseLocal` and `baseRemote` are each
|
|
81
|
+
* side as the last sync left it; they hold the same blocks, though a tick may differ. Blocks line up by their
|
|
82
|
+
* shape, so a checklist tick never collides with a text edit: each tick follows the side that changed it, the
|
|
83
|
+
* remote when neither did. A stretch both sides changed differently is a conflict, settled by `keep` when given.
|
|
70
84
|
*/
|
|
71
|
-
export const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
export const mergeBlocks = (baseLocal, baseRemote, local, remote, keep) => {
|
|
86
|
+
if (baseLocal.length !== baseRemote.length ||
|
|
87
|
+
baseLocal.some((block, index) => blockShape(block) !== blockShape(baseRemote[index])))
|
|
88
|
+
return { status: "unaligned" };
|
|
89
|
+
const tagged = [
|
|
90
|
+
...diffBlocks(baseLocal, local, blockShape).map((hunk) => ({ ...hunk, side: "local" })),
|
|
91
|
+
...diffBlocks(baseRemote, remote, blockShape).map((hunk) => ({ ...hunk, side: "remote" })),
|
|
92
|
+
].sort((a, b) => a.from - b.from || a.to - b.to);
|
|
93
|
+
const clusters = [];
|
|
94
|
+
for (const hunk of tagged) {
|
|
95
|
+
const last = clusters.at(-1);
|
|
96
|
+
if (last?.some((other) => touches(other, hunk)))
|
|
97
|
+
last.push(hunk);
|
|
98
|
+
else
|
|
99
|
+
clusters.push([hunk]);
|
|
100
|
+
}
|
|
101
|
+
const unchangedLocal = new Map(matches(baseLocal, local, blockShape));
|
|
102
|
+
const unchangedRemote = new Map(matches(baseRemote, remote, blockShape));
|
|
103
|
+
/** A base block neither side replaced: it keeps its place and takes the tick of the side that changed it. */
|
|
104
|
+
const kept = (index) => {
|
|
105
|
+
const base = baseLocal[index];
|
|
106
|
+
const mine = local[unchangedLocal.get(index) ?? -1];
|
|
107
|
+
const theirs = remote[unchangedRemote.get(index) ?? -1];
|
|
108
|
+
if (!isCheck(base) || !isCheck(mine) || !isCheck(theirs))
|
|
109
|
+
return mine ?? theirs ?? base;
|
|
110
|
+
const changedHere = Boolean(mine.checked) !== Boolean(base.checked);
|
|
111
|
+
return { ...mine, checked: changedHere ? Boolean(mine.checked) : Boolean(theirs.checked) };
|
|
112
|
+
};
|
|
113
|
+
/** One side's replacement, with the other side's tick carried onto items it edited in place. */
|
|
114
|
+
const oneSide = (hunk) => {
|
|
115
|
+
if (hunk.to - hunk.from !== hunk.blocks.length)
|
|
116
|
+
return hunk.blocks;
|
|
117
|
+
return hunk.blocks.map((block, offset) => {
|
|
118
|
+
const index = hunk.from + offset;
|
|
119
|
+
const base = (hunk.side === "local" ? baseRemote : baseLocal)[index];
|
|
120
|
+
const other = hunk.side === "local" ? remote[unchangedRemote.get(index) ?? -1] : local[unchangedLocal.get(index) ?? -1];
|
|
121
|
+
const own = (hunk.side === "local" ? baseLocal : baseRemote)[index];
|
|
122
|
+
if (!isCheck(block) || !isCheck(base) || !isCheck(other) || !isCheck(own))
|
|
123
|
+
return block;
|
|
124
|
+
if (Boolean(block.checked) !== Boolean(own.checked) || Boolean(other.checked) === Boolean(base.checked))
|
|
125
|
+
return block;
|
|
126
|
+
return { ...block, checked: Boolean(other.checked) };
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
let blocks = [];
|
|
130
|
+
const conflicts = [];
|
|
131
|
+
let at = 0;
|
|
132
|
+
for (const cluster of clusters) {
|
|
133
|
+
const from = cluster.reduce((low, hunk) => Math.min(low, hunk.from), Number.POSITIVE_INFINITY);
|
|
134
|
+
const to = cluster.reduce((high, hunk) => Math.max(high, hunk.to), 0);
|
|
135
|
+
for (; at < from; at++)
|
|
136
|
+
blocks.push(kept(at));
|
|
137
|
+
at = to;
|
|
138
|
+
const mine = cluster.filter((hunk) => hunk.side === "local");
|
|
139
|
+
const theirs = cluster.filter((hunk) => hunk.side === "remote");
|
|
140
|
+
if (!theirs.length || !mine.length) {
|
|
141
|
+
let cursor = from;
|
|
142
|
+
for (const hunk of cluster) {
|
|
143
|
+
for (; cursor < hunk.from; cursor++)
|
|
144
|
+
blocks.push(kept(cursor));
|
|
145
|
+
blocks = blocks.concat(oneSide(hunk));
|
|
146
|
+
cursor = hunk.to;
|
|
147
|
+
}
|
|
148
|
+
for (; cursor < to; cursor++)
|
|
149
|
+
blocks.push(kept(cursor));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const localSide = applied(baseLocal, from, to, mine);
|
|
153
|
+
const remoteSide = applied(baseRemote, from, to, theirs);
|
|
154
|
+
if (sameKeys(localSide, remoteSide)) {
|
|
155
|
+
blocks = blocks.concat(localSide);
|
|
84
156
|
continue;
|
|
85
|
-
if (Boolean(mine.checked) === Boolean(last.checked) && Boolean(remote.checked) !== Boolean(mine.checked)) {
|
|
86
|
-
mine.checked = Boolean(remote.checked);
|
|
87
|
-
ticksKept++;
|
|
88
157
|
}
|
|
158
|
+
conflicts.push({ base: baseLocal.slice(from, to), local: localSide, remote: remoteSide });
|
|
159
|
+
blocks = blocks.concat(keep === "remote" ? [] : localSide, keep === "local" ? [] : remoteSide);
|
|
89
160
|
}
|
|
90
|
-
|
|
161
|
+
for (; at < baseLocal.length; at++)
|
|
162
|
+
blocks.push(kept(at));
|
|
163
|
+
if (conflicts.length && !keep)
|
|
164
|
+
return { status: "conflict", conflicts };
|
|
165
|
+
return { status: "clean", blocks, conflicts };
|
|
91
166
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Block, type Inline } from "../../documents/blocks.js";
|
|
2
|
+
/** Inline runs as Markdown; a run of one link is written as one link. `indent` continues a line after a hard break. */
|
|
3
|
+
export declare const inlineMarkdown: (inlines: Inline[], indent?: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Blocks as Markdown, one after another. `columns` holds, per depth, where the text of the list item before
|
|
6
|
+
* the first block starts, so a nested item lines up under a parent written elsewhere in the file.
|
|
7
|
+
*/
|
|
8
|
+
export declare const blocksMarkdown: (blocks: Block[], columns?: number[]) => string;
|
|
9
|
+
/**
|
|
10
|
+
* The Markdown file `text` changed so that it reads as `next`, touching only the lines of blocks that differ:
|
|
11
|
+
* everything else stays byte for byte, line endings and a byte-order mark included. `normalize` maps the file's
|
|
12
|
+
* blocks one to one onto the form `next` is in. Undefined when the result would not read back as `next`, which
|
|
13
|
+
* a caller reports instead of writing.
|
|
14
|
+
*/
|
|
15
|
+
export declare const spliceMarkdown: (text: string, next: Block[], normalize?: (blocks: Block[]) => Block[]) => Promise<string | undefined>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { blockKey, blockShape, normalizeInlines } from "../../documents/blocks.js";
|
|
2
|
+
import { markdownBlocks, placedMarkdownBlocks } from "../../documents/markdown-blocks.js";
|
|
3
|
+
import { diffBlocks } from "./block-diff.js";
|
|
4
|
+
const escapeText = (text) => text.replace(/[\\`*_[\]<>~|]/g, "\\$&");
|
|
5
|
+
const codeSpan = (text) => {
|
|
6
|
+
const longest = (text.match(/`+/g) ?? []).reduce((most, run) => Math.max(most, run.length), 0);
|
|
7
|
+
const fence = "`".repeat(longest + 1);
|
|
8
|
+
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
9
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
10
|
+
};
|
|
11
|
+
const linkTarget = (url) => (/[\s()<>]/.test(url) ? `<${url.replace(/[<>]/g, encodeURIComponent)}>` : url);
|
|
12
|
+
const styled = (inline) => {
|
|
13
|
+
const text = inline.code ? codeSpan(inline.text) : escapeText(inline.text);
|
|
14
|
+
// A delimiter next to a space does not open or close emphasis, so spaces stay outside the marks.
|
|
15
|
+
const [, lead = "", core = "", trail = ""] = /^(\s*)([\s\S]*?)(\s*)$/.exec(text) ?? [];
|
|
16
|
+
if (!core)
|
|
17
|
+
return text;
|
|
18
|
+
let marked = core;
|
|
19
|
+
if (inline.strike)
|
|
20
|
+
marked = `~~${marked}~~`;
|
|
21
|
+
if (inline.italic)
|
|
22
|
+
marked = `*${marked}*`;
|
|
23
|
+
if (inline.bold)
|
|
24
|
+
marked = `**${marked}**`;
|
|
25
|
+
return `${lead}${marked}${trail}`;
|
|
26
|
+
};
|
|
27
|
+
/** Inline runs as Markdown; a run of one link is written as one link. `indent` continues a line after a hard break. */
|
|
28
|
+
export const inlineMarkdown = (inlines, indent = "") => {
|
|
29
|
+
const runs = normalizeInlines(inlines);
|
|
30
|
+
let out = "";
|
|
31
|
+
for (let at = 0; at < runs.length;) {
|
|
32
|
+
const link = runs[at]?.link;
|
|
33
|
+
let body = "";
|
|
34
|
+
for (; at < runs.length && runs[at]?.link === link; at++)
|
|
35
|
+
body += styled({ ...runs[at], link: undefined });
|
|
36
|
+
out += link ? `[${body}](${linkTarget(link)})` : body;
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
.replace(/^(#{1,6}\s|[-+*>]\s|[=-]+\s*$)/, "\\$1")
|
|
40
|
+
.replace(/^(\d{1,9})([.)]\s)/, "$1\\$2")
|
|
41
|
+
.replaceAll("\n", `\\\n${indent}`);
|
|
42
|
+
};
|
|
43
|
+
const MARKERS = { bullet: "- ", number: "1. ", check: "- " };
|
|
44
|
+
/**
|
|
45
|
+
* Blocks as Markdown, one after another. `columns` holds, per depth, where the text of the list item before
|
|
46
|
+
* the first block starts, so a nested item lines up under a parent written elsewhere in the file.
|
|
47
|
+
*/
|
|
48
|
+
export const blocksMarkdown = (blocks, columns = []) => {
|
|
49
|
+
const lines = [];
|
|
50
|
+
const depths = [...columns];
|
|
51
|
+
let previous;
|
|
52
|
+
for (const block of blocks) {
|
|
53
|
+
if (previous)
|
|
54
|
+
lines.push(previous.kind === "item" && block.kind === "item" ? "\n" : "\n\n");
|
|
55
|
+
previous = block;
|
|
56
|
+
switch (block.kind) {
|
|
57
|
+
case "heading":
|
|
58
|
+
lines.push(`${"#".repeat(block.level)} ${inlineMarkdown(block.inlines)}`);
|
|
59
|
+
break;
|
|
60
|
+
case "paragraph":
|
|
61
|
+
lines.push(inlineMarkdown(block.inlines));
|
|
62
|
+
break;
|
|
63
|
+
case "quote":
|
|
64
|
+
lines.push(`> ${inlineMarkdown(block.inlines, "> ")}`);
|
|
65
|
+
break;
|
|
66
|
+
case "code": {
|
|
67
|
+
const longest = (block.text.match(/^`+/gm) ?? []).reduce((most, run) => Math.max(most, run.length), 2);
|
|
68
|
+
const fence = "`".repeat(longest + 1);
|
|
69
|
+
lines.push(`${fence}${block.language ?? ""}\n${block.text}\n${fence}`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "rule":
|
|
73
|
+
lines.push("---");
|
|
74
|
+
break;
|
|
75
|
+
case "image":
|
|
76
|
+
lines.push(`})`);
|
|
77
|
+
break;
|
|
78
|
+
case "table": {
|
|
79
|
+
const width = block.rows.reduce((most, row) => Math.max(most, row.length), Math.max(block.header.length, 1));
|
|
80
|
+
const row = (cells) => `| ${Array.from({ length: width }, (_, index) => inlineMarkdown(cells[index] ?? []).replaceAll("\\\n", " ") || " ").join(" | ")} |`;
|
|
81
|
+
lines.push([row(block.header), `|${" --- |".repeat(width)}`, ...block.rows.map(row)].join("\n"));
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "item": {
|
|
85
|
+
const indent = block.depth === 0 ? 0 : (depths[block.depth - 1] ?? block.depth * 2);
|
|
86
|
+
const marker = MARKERS[block.list];
|
|
87
|
+
const box = block.list === "check" ? (block.checked ? "[x] " : "[ ] ") : "";
|
|
88
|
+
const column = indent + marker.length;
|
|
89
|
+
depths.length = block.depth;
|
|
90
|
+
depths[block.depth] = column;
|
|
91
|
+
lines.push(`${" ".repeat(indent)}${marker}${box}${inlineMarkdown(block.inlines, " ".repeat(column))}`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines.join("");
|
|
97
|
+
};
|
|
98
|
+
const BLANK_BETWEEN = (a, b) => Boolean(a && b) && !(a?.kind === "item" && b?.kind === "item");
|
|
99
|
+
/**
|
|
100
|
+
* The Markdown file `text` changed so that it reads as `next`, touching only the lines of blocks that differ:
|
|
101
|
+
* everything else stays byte for byte, line endings and a byte-order mark included. `normalize` maps the file's
|
|
102
|
+
* blocks one to one onto the form `next` is in. Undefined when the result would not read back as `next`, which
|
|
103
|
+
* a caller reports instead of writing.
|
|
104
|
+
*/
|
|
105
|
+
export const spliceMarkdown = async (text, next, normalize = (blocks) => blocks) => {
|
|
106
|
+
const bom = text.startsWith("") ? "" : "";
|
|
107
|
+
const crlf = text.includes("\r\n");
|
|
108
|
+
let body = (bom ? text.slice(1) : text).replace(/\r\n/g, "\n");
|
|
109
|
+
const placed = await placedMarkdownBlocks(body);
|
|
110
|
+
const current = normalize(placed.map((item) => item.block));
|
|
111
|
+
if (current.length !== placed.length)
|
|
112
|
+
return undefined;
|
|
113
|
+
// A run of blocks replaced one for one is spliced block by block, so a tick next to an edit still flips in place.
|
|
114
|
+
const single = diffBlocks(current, next).flatMap((hunk) => hunk.to - hunk.from === hunk.blocks.length
|
|
115
|
+
? hunk.blocks.map((block, offset) => ({ from: hunk.from + offset, to: hunk.from + offset + 1, blocks: [block] }))
|
|
116
|
+
: [hunk]);
|
|
117
|
+
const hunks = grouped(single, placed, current);
|
|
118
|
+
if (!hunks.length)
|
|
119
|
+
return text;
|
|
120
|
+
const lineStart = (offset) => body.lastIndexOf("\n", offset - 1) + 1;
|
|
121
|
+
for (const [index, hunk] of [...hunks.entries()].reverse()) {
|
|
122
|
+
const following = hunks[index + 1];
|
|
123
|
+
const toggled = tick(body, hunk, placed, current);
|
|
124
|
+
if (toggled !== undefined) {
|
|
125
|
+
body = toggled;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (body && !body.endsWith("\n"))
|
|
129
|
+
body += "\n";
|
|
130
|
+
const cutStart = hunk.from < placed.length ? lineStart(placed[hunk.from].start) : body.length;
|
|
131
|
+
const cutEnd = hunk.to < placed.length ? lineStart(placed[hunk.to].start) : body.length;
|
|
132
|
+
const before = current[hunk.from - 1];
|
|
133
|
+
const after = following?.from === hunk.to ? (following.blocks[0] ?? current[following.to]) : current[hunk.to];
|
|
134
|
+
let head = body.slice(0, cutStart);
|
|
135
|
+
const first = hunk.blocks[0] ?? after;
|
|
136
|
+
if (BLANK_BETWEEN(before, first) && head && !head.endsWith("\n\n"))
|
|
137
|
+
head += "\n";
|
|
138
|
+
let insert = "";
|
|
139
|
+
if (hunk.blocks.length) {
|
|
140
|
+
insert = `${blocksMarkdown(hunk.blocks, columnsBefore(placed, hunk.from))}\n`;
|
|
141
|
+
if (BLANK_BETWEEN(hunk.blocks.at(-1), after))
|
|
142
|
+
insert += "\n";
|
|
143
|
+
}
|
|
144
|
+
body = head + insert + body.slice(cutEnd);
|
|
145
|
+
}
|
|
146
|
+
const reread = normalize(await markdownBlocks(body));
|
|
147
|
+
if (reread.length !== next.length ||
|
|
148
|
+
reread.some((block, index) => blockKey(block) !== blockKey(next[index])))
|
|
149
|
+
return undefined;
|
|
150
|
+
return bom + (crlf ? body.replace(/\n/g, "\r\n") : body);
|
|
151
|
+
};
|
|
152
|
+
/** A hunk that cuts through blocks sharing one source stretch grows to take all of them. */
|
|
153
|
+
const grouped = (hunks, placed, current) => {
|
|
154
|
+
const shares = (index) => index > 0 && index < placed.length && placed[index]?.start === placed[index - 1]?.start;
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const hunk of hunks) {
|
|
157
|
+
let { from, to } = hunk;
|
|
158
|
+
while (shares(from))
|
|
159
|
+
from--;
|
|
160
|
+
while (shares(to))
|
|
161
|
+
to++;
|
|
162
|
+
const blocks = [...current.slice(from, hunk.from), ...hunk.blocks, ...current.slice(hunk.to, to)];
|
|
163
|
+
const last = out.at(-1);
|
|
164
|
+
if (last && from < last.to) {
|
|
165
|
+
last.blocks = [...last.blocks, ...current.slice(last.to, from), ...blocks.slice(Math.max(0, last.to - from))];
|
|
166
|
+
last.to = Math.max(last.to, to);
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
out.push({ from, to, blocks });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
/** Checklist items whose only change is their tick get their boxes flipped in place. */
|
|
174
|
+
const tick = (body, hunk, placed, current) => {
|
|
175
|
+
if (hunk.to - hunk.from !== hunk.blocks.length)
|
|
176
|
+
return undefined;
|
|
177
|
+
let out = body;
|
|
178
|
+
for (let offset = hunk.blocks.length - 1; offset >= 0; offset--) {
|
|
179
|
+
const before = current[hunk.from + offset];
|
|
180
|
+
const after = hunk.blocks[offset];
|
|
181
|
+
if (before?.kind !== "item" || after?.kind !== "item" || blockShape(before) !== blockShape(after))
|
|
182
|
+
return undefined;
|
|
183
|
+
const { start, end } = placed[hunk.from + offset];
|
|
184
|
+
const line = out.slice(start, end);
|
|
185
|
+
const box = /^(\S+\s+)\[[ xX]\]/.exec(line);
|
|
186
|
+
if (!box)
|
|
187
|
+
return undefined;
|
|
188
|
+
out = `${out.slice(0, start)}${box[1]}[${after.checked ? "x" : " "}]${line.slice(box[0].length)}${out.slice(end)}`;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
};
|
|
192
|
+
/** Where the text of the nearest list item at each depth before `index` starts. */
|
|
193
|
+
const columnsBefore = (placed, index) => {
|
|
194
|
+
const columns = [];
|
|
195
|
+
let lowest = Number.POSITIVE_INFINITY;
|
|
196
|
+
for (let at = index - 1; at >= 0 && lowest > 0; at--) {
|
|
197
|
+
const item = placed[at];
|
|
198
|
+
if (item.block.kind !== "item")
|
|
199
|
+
break;
|
|
200
|
+
if (item.block.depth < lowest) {
|
|
201
|
+
lowest = item.block.depth;
|
|
202
|
+
columns[lowest] = item.column ?? 2 * (lowest + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return columns;
|
|
206
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import type { DocumentTarget, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
|
|
2
|
+
import type { ConflictPart, DocumentTarget, Keep, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
|
|
3
3
|
export interface PushOptions<S> {
|
|
4
4
|
/** Absolute path of the local folder being mirrored. */
|
|
5
5
|
root: string;
|
|
@@ -8,7 +8,10 @@ export interface PushOptions<S> {
|
|
|
8
8
|
target: DocumentTarget<S>;
|
|
9
9
|
state: PushStatePort<S>;
|
|
10
10
|
principal: string;
|
|
11
|
-
|
|
11
|
+
/** Settles stretches both sides changed; without it they are conflicts. */
|
|
12
|
+
keep?: Keep;
|
|
13
|
+
/** Creates again a remote file that was deleted. */
|
|
14
|
+
recreate?: boolean;
|
|
12
15
|
dryRun?: boolean;
|
|
13
16
|
/** Runs one remote write behind the provider's permission checks and write journal. */
|
|
14
17
|
guard<T>(attempt: {
|
|
@@ -23,15 +26,22 @@ export interface PushOptions<S> {
|
|
|
23
26
|
maxFiles?: number;
|
|
24
27
|
now?(): number;
|
|
25
28
|
}
|
|
29
|
+
export interface PushConflict {
|
|
30
|
+
path: string;
|
|
31
|
+
url: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
parts?: ConflictPart[];
|
|
34
|
+
localChangedAt?: string;
|
|
35
|
+
remoteChangedAt?: string;
|
|
36
|
+
remoteChangedBy?: string;
|
|
37
|
+
}
|
|
26
38
|
export interface PushResult {
|
|
27
39
|
created: string[];
|
|
28
40
|
updated: string[];
|
|
41
|
+
/** Local files rewritten with changes made on the remote. */
|
|
42
|
+
pulled: string[];
|
|
29
43
|
unchanged: number;
|
|
30
|
-
conflicts:
|
|
31
|
-
path: string;
|
|
32
|
-
url: string;
|
|
33
|
-
reason: string;
|
|
34
|
-
}[];
|
|
44
|
+
conflicts: PushConflict[];
|
|
35
45
|
missing: string[];
|
|
36
46
|
orphans: {
|
|
37
47
|
path: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { readdir } from "node:fs/promises";
|
|
4
|
-
import { join, posix } from "node:path";
|
|
3
|
+
import { chmod, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
5
5
|
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
6
6
|
const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
|
|
7
7
|
const hashFile = (path) => new Promise((done, fail) => {
|
|
@@ -11,6 +11,27 @@ const hashFile = (path) => new Promise((done, fail) => {
|
|
|
11
11
|
.on("error", fail)
|
|
12
12
|
.on("end", () => done(hash.digest("hex")));
|
|
13
13
|
});
|
|
14
|
+
const hashText = (text) => createHash("sha256").update(text).digest("hex");
|
|
15
|
+
/**
|
|
16
|
+
* Replaces the file through a temporary file beside it, keeping its mode. Undefined, and nothing written, when
|
|
17
|
+
* the file no longer has `hash`: someone saved it during the sync.
|
|
18
|
+
*/
|
|
19
|
+
const replaceLocal = async (file, content) => {
|
|
20
|
+
const temp = join(dirname(file.absolute), `.${basename(file.absolute)}.${randomUUID().slice(0, 8)}.tmp`);
|
|
21
|
+
const { mode } = await stat(file.absolute);
|
|
22
|
+
await writeFile(temp, content, { flag: "wx" });
|
|
23
|
+
try {
|
|
24
|
+
// A mode passed to the write goes through the umask; set it after, so a group-writable file stays one.
|
|
25
|
+
await chmod(temp, mode & 0o777);
|
|
26
|
+
if ((await hashFile(file.absolute)) !== file.hash)
|
|
27
|
+
return undefined;
|
|
28
|
+
await rename(temp, file.absolute);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await rm(temp, { force: true });
|
|
32
|
+
}
|
|
33
|
+
return hashText(content);
|
|
34
|
+
};
|
|
14
35
|
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
15
36
|
export const scanLocal = async (root, kindOf, maxFiles = 10_000) => {
|
|
16
37
|
const files = [];
|
|
@@ -70,7 +91,7 @@ const originOf = (principal, root, folder, path) => createHash("sha256")
|
|
|
70
91
|
.digest("hex")
|
|
71
92
|
.slice(0, 32);
|
|
72
93
|
export const pushDocuments = async (options) => {
|
|
73
|
-
const { root, folder, target, principal,
|
|
94
|
+
const { root, folder, target, principal, keep, recreate = false, signal } = options;
|
|
74
95
|
const now = options.now ?? Date.now;
|
|
75
96
|
if (signal?.aborted)
|
|
76
97
|
throw new CliError("cancelled", "push was cancelled");
|
|
@@ -96,7 +117,8 @@ export const pushDocuments = async (options) => {
|
|
|
96
117
|
const result = {
|
|
97
118
|
created: [],
|
|
98
119
|
updated: [],
|
|
99
|
-
|
|
120
|
+
pulled: [],
|
|
121
|
+
unchanged: 0,
|
|
100
122
|
conflicts: [],
|
|
101
123
|
missing: [],
|
|
102
124
|
orphans: plan.orphans.map((path) => ({ path, url: state.entries[path]?.url ?? "" })),
|
|
@@ -150,9 +172,11 @@ export const pushDocuments = async (options) => {
|
|
|
150
172
|
save();
|
|
151
173
|
result.created.push(file.path);
|
|
152
174
|
};
|
|
175
|
+
// A file unchanged here may have changed remotely, so every mirrored file is read back.
|
|
153
176
|
const work = [
|
|
154
177
|
...plan.create.map((file) => ({ file, create: true })),
|
|
155
178
|
...plan.update.map((file) => ({ file, create: false })),
|
|
179
|
+
...plan.unchanged.map((file) => ({ file, create: false })),
|
|
156
180
|
];
|
|
157
181
|
for (const job of work.sort((a, b) => a.file.path.localeCompare(b.file.path))) {
|
|
158
182
|
if (signal?.aborted) {
|
|
@@ -170,15 +194,24 @@ export const pushDocuments = async (options) => {
|
|
|
170
194
|
id: entry.id,
|
|
171
195
|
file,
|
|
172
196
|
recorded: entry.snapshot,
|
|
173
|
-
|
|
197
|
+
...(keep ? { keep } : {}),
|
|
174
198
|
...(signal ? { signal } : {}),
|
|
175
199
|
});
|
|
176
200
|
if ("conflict" in prepared) {
|
|
177
|
-
|
|
201
|
+
const { mtime } = await stat(file.absolute);
|
|
202
|
+
result.conflicts.push({
|
|
203
|
+
path: file.path,
|
|
204
|
+
url: entry.url,
|
|
205
|
+
reason: prepared.conflict,
|
|
206
|
+
...(prepared.parts ? { parts: prepared.parts } : {}),
|
|
207
|
+
localChangedAt: mtime.toISOString(),
|
|
208
|
+
...(prepared.remote?.changedAt ? { remoteChangedAt: prepared.remote.changedAt } : {}),
|
|
209
|
+
...(prepared.remote?.changedBy ? { remoteChangedBy: prepared.remote.changedBy } : {}),
|
|
210
|
+
});
|
|
178
211
|
continue;
|
|
179
212
|
}
|
|
180
213
|
if ("missing" in prepared) {
|
|
181
|
-
if (!
|
|
214
|
+
if (!recreate) {
|
|
182
215
|
result.missing.push(file.path);
|
|
183
216
|
continue;
|
|
184
217
|
}
|
|
@@ -187,20 +220,41 @@ export const pushDocuments = async (options) => {
|
|
|
187
220
|
continue;
|
|
188
221
|
}
|
|
189
222
|
let pushed;
|
|
223
|
+
let hash = file.hash;
|
|
190
224
|
if ("unchanged" in prepared) {
|
|
191
225
|
pushed = prepared.unchanged;
|
|
192
226
|
result.unchanged++;
|
|
193
227
|
}
|
|
194
228
|
else {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
229
|
+
// The local file goes first: if the remote write then fails, the next sync finds the same change on
|
|
230
|
+
// both sides and merges it once.
|
|
231
|
+
if (prepared.local !== undefined) {
|
|
232
|
+
options.assertHeld();
|
|
233
|
+
const written = await replaceLocal(file, prepared.local);
|
|
234
|
+
if (!written) {
|
|
235
|
+
result.conflicts.push({ path: file.path, url: entry.url, reason: "the local file changed during the sync" });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
hash = written;
|
|
239
|
+
result.pulled.push(file.path);
|
|
240
|
+
if (prepared.remote && prepared.afterLocal !== undefined) {
|
|
241
|
+
state.entries[file.path] = { ...entry, snapshot: prepared.afterLocal, hash };
|
|
242
|
+
save();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (prepared.remote) {
|
|
246
|
+
const operationId = options.operationId();
|
|
247
|
+
options.assertHeld();
|
|
248
|
+
pushed = await options.guard({ action: "update", path: file.path, operationId }, () => prepared.write());
|
|
249
|
+
result.updated.push(file.path);
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
pushed = await prepared.write();
|
|
199
253
|
}
|
|
200
254
|
state.entries[file.path] = {
|
|
201
255
|
...pushed,
|
|
202
256
|
kind: file.kind,
|
|
203
|
-
hash
|
|
257
|
+
hash,
|
|
204
258
|
pushedAt: new Date(now()).toISOString(),
|
|
205
259
|
};
|
|
206
260
|
save();
|
|
@@ -151,7 +151,7 @@ export interface EmailFilter extends MailFilter {
|
|
|
151
151
|
mailbox?: string;
|
|
152
152
|
/** On the received time, or the sent time when that is unknown: IMAP's SINCE counts by the received day. */
|
|
153
153
|
since?: number;
|
|
154
|
-
/** Exclusive. */
|
|
154
|
+
/** Exclusive. `momentOf` and `dateWindow` with `end` give the last millisecond of a day, an inclusive end: add 1. */
|
|
155
155
|
until?: number;
|
|
156
156
|
/** Only emails with an attachment whose text extraction failed with one of these errors. */
|
|
157
157
|
attachmentErrors?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.387.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|