@wirecat/cli-core 0.382.0 → 0.383.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 +21 -0
- package/dist/attachments/documents.d.ts +3 -0
- package/dist/attachments/documents.js +17 -0
- package/dist/documents/blocks.d.ts +45 -0
- package/dist/documents/blocks.js +48 -0
- package/dist/documents/index.d.ts +5 -0
- package/dist/documents/index.js +4 -0
- package/dist/documents/markdown-blocks.d.ts +3 -0
- package/dist/documents/markdown-blocks.js +123 -0
- package/dist/documents/push-model.d.ts +89 -0
- package/dist/documents/push-model.js +1 -0
- package/dist/documents.d.ts +1 -0
- package/dist/documents.js +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/services/documents/block-diff.d.ts +24 -0
- package/dist/services/documents/block-diff.js +91 -0
- package/dist/services/documents/push.d.ts +60 -0
- package/dist/services/documents/push.js +218 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
|
|
|
5
5
|
minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
|
|
6
6
|
shape of this file.
|
|
7
7
|
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.383.0 — 22.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`readSpreadsheet(bytes, "xlsx" | "ods")` from `@wirecat/cli-core/documents`** returns a workbook's cells per
|
|
15
|
+
worksheet (values as displayed), the same reader text extraction uses, so a provider can mirror a workbook's tabs.
|
|
16
|
+
- **Push local files into a provider as native documents (`@wirecat/cli-core/documents`).** `pushDocuments` mirrors
|
|
17
|
+
a local folder one way through a provider's `DocumentTarget`: new files are created, changed files updated, and
|
|
18
|
+
unchanged files skipped by content hash with no remote call. Before a create it saves a pending origin key, so a
|
|
19
|
+
crash after the provider wrote finds the file (`findByOrigin`) instead of creating a duplicate. The provider's
|
|
20
|
+
`prepare` compares the remote with the last push before writing; a remote copy changed since then is a conflict
|
|
21
|
+
unless `force`, and only the `write` step passes the guard. A local delete is reported as an
|
|
22
|
+
orphan and nothing is ever deleted remotely. `planPush` is the pure plan behind `dryRun`; `scanLocal` skips hidden
|
|
23
|
+
files, editor leftovers and symbolic links. Every write passes through the caller's `guard` with its own operation
|
|
24
|
+
id. For documents with structure, `markdownBlocks` (async, its parser loaded on first use) parses Markdown (CommonMark and GFM tables, task lists,
|
|
25
|
+
strikethrough) into `Block`s a provider writes natively, `diffBlocks` gives the edits that leave unchanged blocks
|
|
26
|
+
in place, and `reconcileBlocks` lets a checklist tick change on either side without a conflict. See
|
|
27
|
+
[documents](docs/dev/DOCUMENTS.md#push-local-files-to-a-provider).
|
|
28
|
+
|
|
8
29
|
## 0.382.0 — 22.10.2026
|
|
9
30
|
|
|
10
31
|
### Added
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Extraction, FileHint } from "../readers/types.js";
|
|
2
|
+
import { type Sheet } from "./sheets.js";
|
|
2
3
|
export type DocumentKind = "odt" | "ods" | "odp" | "xlsx" | "pptx" | "epub";
|
|
3
4
|
export declare const documentKind: (hint: FileHint) => DocumentKind | undefined;
|
|
4
5
|
/** v2 for spreadsheets: rows under their header instead of one line per cell, so older text is read again. */
|
|
5
6
|
export declare const documentExtractor: (kind: DocumentKind) => string;
|
|
7
|
+
/** The cells of an XLSX or ODS workbook, one `Sheet` per worksheet in workbook order, values as displayed. */
|
|
8
|
+
export declare const readSpreadsheet: (bytes: Uint8Array, kind: "xlsx" | "ods", signal?: AbortSignal) => Promise<Sheet[]>;
|
|
6
9
|
export declare const readDocument: (bytes: Uint8Array, kind: DocumentKind, signal?: AbortSignal) => Promise<Extraction>;
|
|
@@ -393,6 +393,23 @@ const readBook = (archive, output) => {
|
|
|
393
393
|
visit(bodies[0]);
|
|
394
394
|
}
|
|
395
395
|
};
|
|
396
|
+
/** The cells of an XLSX or ODS workbook, one `Sheet` per worksheet in workbook order, values as displayed. */
|
|
397
|
+
export const readSpreadsheet = async (bytes, kind, signal) => {
|
|
398
|
+
try {
|
|
399
|
+
const archive = await readContainer(bytes, signal);
|
|
400
|
+
const sheets = kind === "xlsx" ? readSheet(archive) : readOdf(archive, kind, new Output());
|
|
401
|
+
if (!sheets)
|
|
402
|
+
throw new Error("wrong_format");
|
|
403
|
+
return sheets;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (error instanceof CliError)
|
|
407
|
+
throw error;
|
|
408
|
+
throw new CliError("validation_error", `the ${kind.toUpperCase()} workbook could not be read`, {
|
|
409
|
+
reason: "spreadsheet.unreadable",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
};
|
|
396
413
|
export const readDocument = async (bytes, kind, signal) => {
|
|
397
414
|
const extractor = documentExtractor(kind);
|
|
398
415
|
try {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface Inline {
|
|
2
|
+
text: string;
|
|
3
|
+
bold?: true;
|
|
4
|
+
italic?: true;
|
|
5
|
+
strike?: true;
|
|
6
|
+
code?: true;
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
export type ListKind = "bullet" | "number" | "check";
|
|
10
|
+
export type Block = {
|
|
11
|
+
kind: "heading";
|
|
12
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
13
|
+
inlines: Inline[];
|
|
14
|
+
} | {
|
|
15
|
+
kind: "paragraph";
|
|
16
|
+
inlines: Inline[];
|
|
17
|
+
} | {
|
|
18
|
+
kind: "item";
|
|
19
|
+
list: ListKind;
|
|
20
|
+
depth: number;
|
|
21
|
+
checked?: boolean;
|
|
22
|
+
inlines: Inline[];
|
|
23
|
+
} | {
|
|
24
|
+
kind: "code";
|
|
25
|
+
language?: string;
|
|
26
|
+
text: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "quote";
|
|
29
|
+
inlines: Inline[];
|
|
30
|
+
} | {
|
|
31
|
+
kind: "table";
|
|
32
|
+
header: Inline[][];
|
|
33
|
+
rows: Inline[][][];
|
|
34
|
+
} | {
|
|
35
|
+
kind: "image";
|
|
36
|
+
url: string;
|
|
37
|
+
alt: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "rule";
|
|
40
|
+
};
|
|
41
|
+
/** Adjacent runs with the same marks merge and empty runs drop, so two equal texts give one key. */
|
|
42
|
+
export declare const normalizeInlines: (inlines: Inline[]) => Inline[];
|
|
43
|
+
export declare const blockKey: (block: Block) => string;
|
|
44
|
+
/** The same key with checklist ticks left out: two blocks that differ only in a tick share it. */
|
|
45
|
+
export declare const blockShape: (block: Block) => string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const MARKS = ["bold", "italic", "strike", "code", "link"];
|
|
2
|
+
const sameMarks = (a, b) => MARKS.every((mark) => a[mark] === b[mark]);
|
|
3
|
+
/** Adjacent runs with the same marks merge and empty runs drop, so two equal texts give one key. */
|
|
4
|
+
export const normalizeInlines = (inlines) => {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const inline of inlines) {
|
|
7
|
+
if (!inline.text)
|
|
8
|
+
continue;
|
|
9
|
+
const last = out.at(-1);
|
|
10
|
+
if (last && sameMarks(last, inline))
|
|
11
|
+
last.text += inline.text;
|
|
12
|
+
else
|
|
13
|
+
out.push({ ...inline });
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
};
|
|
17
|
+
const inlineKey = (inline) => [inline.text, ...MARKS.map((mark) => inline[mark] ?? "")];
|
|
18
|
+
const key = (block, withChecked) => {
|
|
19
|
+
switch (block.kind) {
|
|
20
|
+
case "heading":
|
|
21
|
+
return [block.kind, block.level, normalizeInlines(block.inlines).map(inlineKey)];
|
|
22
|
+
case "paragraph":
|
|
23
|
+
case "quote":
|
|
24
|
+
return [block.kind, normalizeInlines(block.inlines).map(inlineKey)];
|
|
25
|
+
case "item":
|
|
26
|
+
return [
|
|
27
|
+
block.kind,
|
|
28
|
+
block.list,
|
|
29
|
+
block.depth,
|
|
30
|
+
withChecked && block.list === "check" ? Boolean(block.checked) : "",
|
|
31
|
+
normalizeInlines(block.inlines).map(inlineKey),
|
|
32
|
+
];
|
|
33
|
+
case "code":
|
|
34
|
+
return [block.kind, block.language ?? "", block.text];
|
|
35
|
+
case "table":
|
|
36
|
+
return [
|
|
37
|
+
block.kind,
|
|
38
|
+
[block.header, ...block.rows].map((row) => row.map((cell) => normalizeInlines(cell).map(inlineKey))),
|
|
39
|
+
];
|
|
40
|
+
case "image":
|
|
41
|
+
return [block.kind, block.url, block.alt];
|
|
42
|
+
case "rule":
|
|
43
|
+
return [block.kind];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export const blockKey = (block) => JSON.stringify(key(block, true));
|
|
47
|
+
/** The same key with checklist ticks left out: two blocks that differ only in a tick share it. */
|
|
48
|
+
export const blockShape = (block) => JSON.stringify(key(block, false));
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
export { type BlockHunk, diffBlocks, type Reconciled, reconcileBlocks } from "../services/documents/block-diff.js";
|
|
1
2
|
export { type Complete, extractMemories } from "../services/documents/extraction.js";
|
|
2
3
|
export { documentIngestion, type IngestionOptions } from "../services/documents/ingestion.js";
|
|
3
4
|
export { type DocumentKnowledge, documentKnowledge } from "../services/documents/knowledge.js";
|
|
4
5
|
export type { SyncOptions } from "../services/documents/ports.js";
|
|
5
6
|
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";
|
|
6
8
|
export { type SyncResult, syncDocuments } from "../services/documents/sync.js";
|
|
9
|
+
export { type Block, blockKey, blockShape, type Inline, type ListKind, normalizeInlines } from "./blocks.js";
|
|
7
10
|
export { catalogFiles } from "./catalog.js";
|
|
11
|
+
export { markdownBlocks } from "./markdown-blocks.js";
|
|
8
12
|
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";
|
|
9
14
|
export { within } from "./scope.js";
|
package/dist/documents/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
export { diffBlocks, reconcileBlocks } from "../services/documents/block-diff.js";
|
|
1
2
|
export { extractMemories } from "../services/documents/extraction.js";
|
|
2
3
|
export { documentIngestion } from "../services/documents/ingestion.js";
|
|
3
4
|
export { documentKnowledge } from "../services/documents/knowledge.js";
|
|
4
5
|
export { extractDocumentMemories } from "../services/documents/proposals.js";
|
|
6
|
+
export { planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
|
|
5
7
|
export { syncDocuments } from "../services/documents/sync.js";
|
|
8
|
+
export { blockKey, blockShape, normalizeInlines } from "./blocks.js";
|
|
6
9
|
export { catalogFiles } from "./catalog.js";
|
|
10
|
+
export { markdownBlocks } from "./markdown-blocks.js";
|
|
7
11
|
export { within } from "./scope.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { normalizeInlines } from "./blocks.js";
|
|
2
|
+
// Loaded on first use: importing the documents entry point would otherwise cost every command about 50 ms.
|
|
3
|
+
let parser;
|
|
4
|
+
const loadParser = () => (parser ??= Promise.all([
|
|
5
|
+
import("mdast-util-from-markdown"),
|
|
6
|
+
import("mdast-util-gfm"),
|
|
7
|
+
import("micromark-extension-gfm"),
|
|
8
|
+
]).then(([{ fromMarkdown }, { gfmFromMarkdown }, { gfm }]) => (markdown) => fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })));
|
|
9
|
+
/** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
|
|
10
|
+
export const markdownBlocks = async (markdown) => {
|
|
11
|
+
const tree = (await loadParser())(markdown);
|
|
12
|
+
const definitions = new Map();
|
|
13
|
+
const collect = (node) => {
|
|
14
|
+
if (node.type === "definition")
|
|
15
|
+
definitions.set(node.identifier, node);
|
|
16
|
+
if ("children" in node)
|
|
17
|
+
for (const child of node.children)
|
|
18
|
+
collect(child);
|
|
19
|
+
};
|
|
20
|
+
collect(tree);
|
|
21
|
+
const images = [];
|
|
22
|
+
const inlinesOf = (nodes, marks = {}) => nodes.flatMap((node) => {
|
|
23
|
+
switch (node.type) {
|
|
24
|
+
case "text":
|
|
25
|
+
return [{ ...marks, text: node.value.replace(/\n/g, " ") }];
|
|
26
|
+
case "inlineCode":
|
|
27
|
+
return [{ ...marks, code: true, text: node.value }];
|
|
28
|
+
case "strong":
|
|
29
|
+
return inlinesOf(node.children, { ...marks, bold: true });
|
|
30
|
+
case "emphasis":
|
|
31
|
+
return inlinesOf(node.children, { ...marks, italic: true });
|
|
32
|
+
case "delete":
|
|
33
|
+
return inlinesOf(node.children, { ...marks, strike: true });
|
|
34
|
+
case "link":
|
|
35
|
+
return inlinesOf(node.children, { ...marks, link: node.url });
|
|
36
|
+
case "linkReference": {
|
|
37
|
+
const url = definitions.get(node.identifier)?.url;
|
|
38
|
+
return inlinesOf(node.children, url ? { ...marks, link: url } : marks);
|
|
39
|
+
}
|
|
40
|
+
case "break":
|
|
41
|
+
return [{ ...marks, text: "\n" }];
|
|
42
|
+
case "image":
|
|
43
|
+
images.push({ kind: "image", url: node.url, alt: node.alt ?? "" });
|
|
44
|
+
return [];
|
|
45
|
+
case "imageReference": {
|
|
46
|
+
const url = definitions.get(node.identifier)?.url;
|
|
47
|
+
if (url)
|
|
48
|
+
images.push({ kind: "image", url, alt: node.alt ?? "" });
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
case "html":
|
|
52
|
+
return [{ ...marks, text: node.value }];
|
|
53
|
+
default:
|
|
54
|
+
return "children" in node ? inlinesOf(node.children, marks) : [];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const trimmed = (inlines) => {
|
|
58
|
+
const out = normalizeInlines(inlines);
|
|
59
|
+
const first = out[0];
|
|
60
|
+
const last = out.at(-1);
|
|
61
|
+
if (first)
|
|
62
|
+
first.text = first.text.trimStart();
|
|
63
|
+
if (last)
|
|
64
|
+
last.text = last.text.trimEnd();
|
|
65
|
+
return normalizeInlines(out);
|
|
66
|
+
};
|
|
67
|
+
const withImages = (block) => {
|
|
68
|
+
const found = images.splice(0);
|
|
69
|
+
if (block?.kind === "paragraph" && !block.inlines.length)
|
|
70
|
+
return found;
|
|
71
|
+
return block ? [block, ...found] : found;
|
|
72
|
+
};
|
|
73
|
+
const textOf = (node) => node.children.flatMap((child) => child.type === "paragraph" || child.type === "heading" ? inlinesOf(child.children) : []);
|
|
74
|
+
const blocks = (nodes, depth = 0) => nodes.flatMap((node) => {
|
|
75
|
+
switch (node.type) {
|
|
76
|
+
case "heading":
|
|
77
|
+
return withImages({ kind: "heading", level: node.depth, inlines: trimmed(inlinesOf(node.children)) });
|
|
78
|
+
case "paragraph":
|
|
79
|
+
return withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(node.children)) });
|
|
80
|
+
case "code":
|
|
81
|
+
return [{ kind: "code", ...(node.lang ? { language: node.lang } : {}), text: node.value }];
|
|
82
|
+
case "blockquote":
|
|
83
|
+
return node.children.flatMap((child) => child.type === "paragraph" || child.type === "heading"
|
|
84
|
+
? withImages({ kind: "quote", inlines: trimmed(inlinesOf(child.children)) })
|
|
85
|
+
: blocks([child], depth));
|
|
86
|
+
case "thematicBreak":
|
|
87
|
+
return [{ kind: "rule" }];
|
|
88
|
+
case "table": {
|
|
89
|
+
const [head, ...rows] = node.children.map((row) => row.children.map((cell) => trimmed(inlinesOf(cell.children))));
|
|
90
|
+
return [{ kind: "table", header: head ?? [], rows }];
|
|
91
|
+
}
|
|
92
|
+
case "list":
|
|
93
|
+
return node.children.flatMap((item) => {
|
|
94
|
+
const [first, ...rest] = item.children;
|
|
95
|
+
const head = {
|
|
96
|
+
kind: "item",
|
|
97
|
+
list: (item.checked != null ? "check" : node.ordered ? "number" : "bullet"),
|
|
98
|
+
depth,
|
|
99
|
+
...(item.checked != null ? { checked: item.checked } : {}),
|
|
100
|
+
inlines: trimmed(first && first.type === "paragraph" ? inlinesOf(first.children) : []),
|
|
101
|
+
};
|
|
102
|
+
const tail = first && first.type !== "paragraph" ? [first, ...rest] : rest;
|
|
103
|
+
return [
|
|
104
|
+
...withImages(head),
|
|
105
|
+
...tail.flatMap((child) => child.type === "list"
|
|
106
|
+
? blocks([child], depth + 1)
|
|
107
|
+
: child.type === "paragraph"
|
|
108
|
+
? withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(child.children)) })
|
|
109
|
+
: blocks([child], depth)),
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
case "html":
|
|
113
|
+
return [{ kind: "paragraph", inlines: [{ text: node.value }] }];
|
|
114
|
+
case "definition":
|
|
115
|
+
case "footnoteDefinition":
|
|
116
|
+
case "yaml":
|
|
117
|
+
return [];
|
|
118
|
+
default:
|
|
119
|
+
return "children" in node ? withImages({ kind: "paragraph", inlines: trimmed(textOf(node)) }) : [];
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return blocks(tree.children);
|
|
123
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** A local file a push mirrors: its path relative to the mirrored folder, with `/` separators. */
|
|
2
|
+
export interface LocalDocument {
|
|
3
|
+
path: string;
|
|
4
|
+
absolute: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Pushed<S> {
|
|
9
|
+
id: string;
|
|
10
|
+
url: string;
|
|
11
|
+
/** What the provider reads back after its own write; the next push compares the remote against it. */
|
|
12
|
+
snapshot: S;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What an update found before writing. Only `write` reaches the remote, so a conflict or a file with nothing to
|
|
16
|
+
* change never counts as a write.
|
|
17
|
+
*/
|
|
18
|
+
export type Prepared<S> = {
|
|
19
|
+
conflict: string;
|
|
20
|
+
} | {
|
|
21
|
+
missing: true;
|
|
22
|
+
} | {
|
|
23
|
+
unchanged: Pushed<S>;
|
|
24
|
+
} | {
|
|
25
|
+
write(): Promise<Pushed<S>>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A provider that writes local files as its own native documents. Every write is one provider call per
|
|
29
|
+
* file (or one all-or-nothing batch): a write cut in half would read back as somebody else's edit.
|
|
30
|
+
*/
|
|
31
|
+
export interface DocumentTarget<S> {
|
|
32
|
+
identity(): Promise<string>;
|
|
33
|
+
/** The provider's document kind for a local path, or undefined when the path is not pushed. */
|
|
34
|
+
kindOf(path: string): string | undefined;
|
|
35
|
+
/** Finds the folder carrying `origin` or creates it: a crash between create and save must not duplicate it. */
|
|
36
|
+
ensureFolder(input: {
|
|
37
|
+
parent: string;
|
|
38
|
+
name: string;
|
|
39
|
+
origin: string;
|
|
40
|
+
}): Promise<string>;
|
|
41
|
+
findByOrigin(origin: string): Promise<Pushed<S> | undefined>;
|
|
42
|
+
create(input: {
|
|
43
|
+
file: LocalDocument;
|
|
44
|
+
name: string;
|
|
45
|
+
parent: string;
|
|
46
|
+
origin: string;
|
|
47
|
+
operationId: string;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}): Promise<Pushed<S>>;
|
|
50
|
+
/** Reads the remote and compares it with `recorded`; a conflict instead of a write unless `force`. */
|
|
51
|
+
prepare(input: {
|
|
52
|
+
id: string;
|
|
53
|
+
file: LocalDocument;
|
|
54
|
+
recorded: S;
|
|
55
|
+
force: boolean;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
}): Promise<Prepared<S>>;
|
|
58
|
+
}
|
|
59
|
+
export interface PushEntry<S> {
|
|
60
|
+
id: string;
|
|
61
|
+
url: string;
|
|
62
|
+
kind: string;
|
|
63
|
+
hash: string;
|
|
64
|
+
snapshot: S;
|
|
65
|
+
pushedAt: string;
|
|
66
|
+
}
|
|
67
|
+
export interface PushState<S> {
|
|
68
|
+
version: 1;
|
|
69
|
+
principal: string;
|
|
70
|
+
root: string;
|
|
71
|
+
target: string;
|
|
72
|
+
folders: Record<string, string>;
|
|
73
|
+
entries: Record<string, PushEntry<S>>;
|
|
74
|
+
/** Saved before a create, so a crash after the provider wrote but before this state was saved finds the file. */
|
|
75
|
+
pending: Record<string, {
|
|
76
|
+
origin: string;
|
|
77
|
+
operationId: string;
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
export interface PushStatePort<S> {
|
|
81
|
+
read(): PushState<S>;
|
|
82
|
+
save(state: PushState<S>): void;
|
|
83
|
+
}
|
|
84
|
+
export interface PushPlan {
|
|
85
|
+
create: LocalDocument[];
|
|
86
|
+
update: LocalDocument[];
|
|
87
|
+
unchanged: LocalDocument[];
|
|
88
|
+
orphans: string[];
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/documents.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { readSpreadsheet } from "./attachments/documents.js";
|
|
1
2
|
export type { Engine, Extraction, FileHint, LoadEngine } from "./attachments/extract.js";
|
|
2
3
|
export { extractText, importEngine, MAX_FILE_BYTES, MAX_TEXT_CHARS } from "./attachments/extract.js";
|
|
3
4
|
export { attachmentMaxBytes } from "./attachments/limits.js";
|
package/dist/documents.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { readSpreadsheet } from "./attachments/documents.js";
|
|
1
2
|
export { extractText, importEngine, MAX_FILE_BYTES, MAX_TEXT_CHARS } from "./attachments/extract.js";
|
|
2
3
|
export { attachmentMaxBytes } from "./attachments/limits.js";
|
|
3
4
|
export { columnName, sheetText } from "./attachments/sheets.js";
|
package/dist/errors/reasons.js
CHANGED
|
@@ -26,6 +26,9 @@ const OTHERS = [
|
|
|
26
26
|
validation("extraction.text-too-long", "the text is too long for memory extraction"),
|
|
27
27
|
validation("file.locked", "another process holds the file's lock"),
|
|
28
28
|
["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
|
|
29
|
+
validation("push.too-many-files", "the folder holds more files than one push takes"),
|
|
30
|
+
validation("push.unreadable", "the folder to push could not be read"),
|
|
31
|
+
validation("spreadsheet.unreadable", "the workbook could not be read"),
|
|
29
32
|
validation("store.refused", "the local store refused the document text"),
|
|
30
33
|
["store.not-writable", "permission_error", "the message store cannot be written from this process"],
|
|
31
34
|
validation("catalog.scope-limit", "the selected catalog exceeds its entry limit"),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Block } from "../../documents/blocks.js";
|
|
2
|
+
/** `base[from, to)` is replaced by `blocks`; an empty range is an insert, an empty `blocks` a delete. */
|
|
3
|
+
export interface BlockHunk {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
blocks: Block[];
|
|
7
|
+
}
|
|
8
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
9
|
+
export declare const diffBlocks: (base: Block[], next: Block[]) => BlockHunk[];
|
|
10
|
+
export type Reconciled = {
|
|
11
|
+
status: "clean";
|
|
12
|
+
blocks: Block[];
|
|
13
|
+
ticksKept: number;
|
|
14
|
+
} | {
|
|
15
|
+
status: "conflict";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
19
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
20
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
21
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
22
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
23
|
+
*/
|
|
24
|
+
export declare const reconcileBlocks: (recorded: Block[], current: Block[], local: Block[], lastLocal: Block[]) => Reconciled;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { blockKey, blockShape } from "../../documents/blocks.js";
|
|
2
|
+
const MAX_CELLS = 4_000_000;
|
|
3
|
+
/** Longest common subsequence: index pairs [inA, inB] of blocks with equal keys, in order. */
|
|
4
|
+
const matches = (a, b, keyOf) => {
|
|
5
|
+
const ka = a.map(keyOf);
|
|
6
|
+
const kb = b.map(keyOf);
|
|
7
|
+
let start = 0;
|
|
8
|
+
while (start < ka.length && start < kb.length && ka[start] === kb[start])
|
|
9
|
+
start++;
|
|
10
|
+
let endA = ka.length;
|
|
11
|
+
let endB = kb.length;
|
|
12
|
+
while (endA > start && endB > start && ka[endA - 1] === kb[endB - 1]) {
|
|
13
|
+
endA--;
|
|
14
|
+
endB--;
|
|
15
|
+
}
|
|
16
|
+
const rows = endA - start;
|
|
17
|
+
const cols = endB - start;
|
|
18
|
+
const pairs = [];
|
|
19
|
+
for (let i = 0; i < start; i++)
|
|
20
|
+
pairs.push([i, i]);
|
|
21
|
+
if (rows * cols > MAX_CELLS) {
|
|
22
|
+
// Too large to align: the changed middle is rewritten as one block of edits.
|
|
23
|
+
}
|
|
24
|
+
else if (rows && cols) {
|
|
25
|
+
const table = new Uint32Array((rows + 1) * (cols + 1));
|
|
26
|
+
for (let i = rows - 1; i >= 0; i--)
|
|
27
|
+
for (let j = cols - 1; j >= 0; j--)
|
|
28
|
+
table[i * (cols + 1) + j] =
|
|
29
|
+
ka[start + i] === kb[start + j]
|
|
30
|
+
? table[(i + 1) * (cols + 1) + j + 1] + 1
|
|
31
|
+
: Math.max(table[(i + 1) * (cols + 1) + j], table[i * (cols + 1) + j + 1]);
|
|
32
|
+
let i = 0;
|
|
33
|
+
let j = 0;
|
|
34
|
+
while (i < rows && j < cols) {
|
|
35
|
+
if (ka[start + i] === kb[start + j]) {
|
|
36
|
+
pairs.push([start + i, start + j]);
|
|
37
|
+
i++;
|
|
38
|
+
j++;
|
|
39
|
+
}
|
|
40
|
+
else if (table[(i + 1) * (cols + 1) + j] >= table[i * (cols + 1) + j + 1])
|
|
41
|
+
i++;
|
|
42
|
+
else
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (let offset = 0; offset < ka.length - endA; offset++)
|
|
47
|
+
pairs.push([endA + offset, endB + offset]);
|
|
48
|
+
return pairs;
|
|
49
|
+
};
|
|
50
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
51
|
+
export const diffBlocks = (base, next) => {
|
|
52
|
+
const hunks = [];
|
|
53
|
+
let a = 0;
|
|
54
|
+
let b = 0;
|
|
55
|
+
for (const [i, j] of [...matches(base, next, blockKey), [base.length, next.length]]) {
|
|
56
|
+
if (i > a || j > b)
|
|
57
|
+
hunks.push({ from: a, to: i, blocks: next.slice(b, j) });
|
|
58
|
+
a = i + 1;
|
|
59
|
+
b = j + 1;
|
|
60
|
+
}
|
|
61
|
+
return hunks;
|
|
62
|
+
};
|
|
63
|
+
const isCheck = (block) => block?.kind === "item" && block.list === "check";
|
|
64
|
+
/**
|
|
65
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
66
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
67
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
68
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
69
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
70
|
+
*/
|
|
71
|
+
export const reconcileBlocks = (recorded, current, local, lastLocal) => {
|
|
72
|
+
const sameShape = recorded.length === current.length &&
|
|
73
|
+
recorded.every((block, index) => blockShape(block) === blockShape(current[index]));
|
|
74
|
+
if (!sameShape)
|
|
75
|
+
return { status: "conflict" };
|
|
76
|
+
const before = new Map(matches(local, lastLocal, blockShape));
|
|
77
|
+
const blocks = local.map((block) => ({ ...block }));
|
|
78
|
+
let ticksKept = 0;
|
|
79
|
+
for (const [i, j] of matches(current, local, blockShape)) {
|
|
80
|
+
const remote = current[i];
|
|
81
|
+
const mine = blocks[j];
|
|
82
|
+
const last = lastLocal[before.get(j) ?? -1];
|
|
83
|
+
if (!isCheck(remote) || !isCheck(mine) || !isCheck(last))
|
|
84
|
+
continue;
|
|
85
|
+
if (Boolean(mine.checked) === Boolean(last.checked) && Boolean(remote.checked) !== Boolean(mine.checked)) {
|
|
86
|
+
mine.checked = Boolean(remote.checked);
|
|
87
|
+
ticksKept++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { status: "clean", blocks, ticksKept };
|
|
91
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import type { DocumentTarget, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
|
|
3
|
+
export interface PushOptions<S> {
|
|
4
|
+
/** Absolute path of the local folder being mirrored. */
|
|
5
|
+
root: string;
|
|
6
|
+
/** Remote folder id the mirror writes into. */
|
|
7
|
+
folder: string;
|
|
8
|
+
target: DocumentTarget<S>;
|
|
9
|
+
state: PushStatePort<S>;
|
|
10
|
+
principal: string;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
/** Runs one remote write behind the provider's permission checks and write journal. */
|
|
14
|
+
guard<T>(attempt: {
|
|
15
|
+
action: "create" | "update" | "folder";
|
|
16
|
+
path: string;
|
|
17
|
+
operationId: string;
|
|
18
|
+
}, act: () => Promise<T>): Promise<T>;
|
|
19
|
+
operationId(): string;
|
|
20
|
+
errorOf(error: unknown): CliError;
|
|
21
|
+
assertHeld(): void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
now?(): number;
|
|
25
|
+
}
|
|
26
|
+
export interface PushResult {
|
|
27
|
+
created: string[];
|
|
28
|
+
updated: string[];
|
|
29
|
+
unchanged: number;
|
|
30
|
+
conflicts: {
|
|
31
|
+
path: string;
|
|
32
|
+
url: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}[];
|
|
35
|
+
missing: string[];
|
|
36
|
+
orphans: {
|
|
37
|
+
path: string;
|
|
38
|
+
url: string;
|
|
39
|
+
}[];
|
|
40
|
+
errors: {
|
|
41
|
+
path: string;
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
}[];
|
|
45
|
+
unsupported: number;
|
|
46
|
+
plan?: {
|
|
47
|
+
create: string[];
|
|
48
|
+
update: string[];
|
|
49
|
+
orphans: string[];
|
|
50
|
+
};
|
|
51
|
+
stopped?: true;
|
|
52
|
+
}
|
|
53
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
54
|
+
export declare const scanLocal: (root: string, kindOf: (path: string) => string | undefined, maxFiles?: number) => Promise<{
|
|
55
|
+
files: LocalDocument[];
|
|
56
|
+
unsupported: number;
|
|
57
|
+
}>;
|
|
58
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
59
|
+
export declare const planPush: <S>(files: LocalDocument[], state: PushState<S>) => PushPlan;
|
|
60
|
+
export declare const pushDocuments: <S>(options: PushOptions<S>) => Promise<PushResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
6
|
+
const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
|
|
7
|
+
const hashFile = (path) => new Promise((done, fail) => {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
createReadStream(path)
|
|
10
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
11
|
+
.on("error", fail)
|
|
12
|
+
.on("end", () => done(hash.digest("hex")));
|
|
13
|
+
});
|
|
14
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
15
|
+
export const scanLocal = async (root, kindOf, maxFiles = 10_000) => {
|
|
16
|
+
const files = [];
|
|
17
|
+
let unsupported = 0;
|
|
18
|
+
const walk = async (relative) => {
|
|
19
|
+
const entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
20
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
21
|
+
if (SKIPPED.test(entry.name) || entry.isSymbolicLink())
|
|
22
|
+
continue;
|
|
23
|
+
const path = relative ? posix.join(relative, entry.name) : entry.name;
|
|
24
|
+
if (entry.isDirectory())
|
|
25
|
+
await walk(path);
|
|
26
|
+
else if (entry.isFile()) {
|
|
27
|
+
const kind = kindOf(path);
|
|
28
|
+
if (!kind) {
|
|
29
|
+
unsupported++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (files.length >= maxFiles)
|
|
33
|
+
throw new CliError("validation_error", `the folder holds more than ${maxFiles} files to push`, {
|
|
34
|
+
reason: "push.too-many-files",
|
|
35
|
+
});
|
|
36
|
+
const absolute = join(root, path);
|
|
37
|
+
files.push({ path, absolute, kind, hash: await hashFile(absolute) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await walk("");
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isCliError(error))
|
|
46
|
+
throw error;
|
|
47
|
+
throw new CliError("validation_error", "the folder to push could not be read", { reason: "push.unreadable" });
|
|
48
|
+
}
|
|
49
|
+
return { files, unsupported };
|
|
50
|
+
};
|
|
51
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
52
|
+
export const planPush = (files, state) => {
|
|
53
|
+
const plan = { create: [], update: [], unchanged: [], orphans: [] };
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
seen.add(file.path);
|
|
57
|
+
const entry = state.entries[file.path];
|
|
58
|
+
if (!entry)
|
|
59
|
+
plan.create.push(file);
|
|
60
|
+
else if (entry.hash !== file.hash)
|
|
61
|
+
plan.update.push(file);
|
|
62
|
+
else
|
|
63
|
+
plan.unchanged.push(file);
|
|
64
|
+
}
|
|
65
|
+
plan.orphans = Object.keys(state.entries).filter((path) => !seen.has(path));
|
|
66
|
+
return plan;
|
|
67
|
+
};
|
|
68
|
+
const originOf = (principal, root, folder, path) => createHash("sha256")
|
|
69
|
+
.update(JSON.stringify([principal, root, folder, path]))
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 32);
|
|
72
|
+
export const pushDocuments = async (options) => {
|
|
73
|
+
const { root, folder, target, principal, force = false, signal } = options;
|
|
74
|
+
const now = options.now ?? Date.now;
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
throw new CliError("cancelled", "push was cancelled");
|
|
77
|
+
if ((await target.identity()) !== principal)
|
|
78
|
+
throw new CliError("authentication_error", "signed-in account does not match this profile", {
|
|
79
|
+
reason: "profile.wrong-account",
|
|
80
|
+
});
|
|
81
|
+
const state = options.state.read();
|
|
82
|
+
if (state.principal !== principal || state.root !== root || state.target !== folder)
|
|
83
|
+
throw new CliError("configuration_error", "push state belongs to another account or mirror");
|
|
84
|
+
const save = () => options.state.save(state);
|
|
85
|
+
if (!options.dryRun)
|
|
86
|
+
for (const [path, pending] of Object.entries(state.pending)) {
|
|
87
|
+
const found = await target.findByOrigin(pending.origin);
|
|
88
|
+
// An empty hash makes this pass update the found file from the local one.
|
|
89
|
+
if (found)
|
|
90
|
+
state.entries[path] = { ...found, kind: target.kindOf(path) ?? "", hash: "", pushedAt: "" };
|
|
91
|
+
delete state.pending[path];
|
|
92
|
+
save();
|
|
93
|
+
}
|
|
94
|
+
const { files, unsupported } = await scanLocal(root, (path) => target.kindOf(path), options.maxFiles);
|
|
95
|
+
const plan = planPush(files, state);
|
|
96
|
+
const result = {
|
|
97
|
+
created: [],
|
|
98
|
+
updated: [],
|
|
99
|
+
unchanged: plan.unchanged.length,
|
|
100
|
+
conflicts: [],
|
|
101
|
+
missing: [],
|
|
102
|
+
orphans: plan.orphans.map((path) => ({ path, url: state.entries[path]?.url ?? "" })),
|
|
103
|
+
errors: [],
|
|
104
|
+
unsupported,
|
|
105
|
+
};
|
|
106
|
+
if (options.dryRun) {
|
|
107
|
+
result.plan = {
|
|
108
|
+
create: plan.create.map((file) => file.path),
|
|
109
|
+
update: plan.update.map((file) => file.path),
|
|
110
|
+
orphans: plan.orphans,
|
|
111
|
+
};
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const folderOf = async (path) => {
|
|
115
|
+
const directory = posix.dirname(path);
|
|
116
|
+
if (directory === ".")
|
|
117
|
+
return folder;
|
|
118
|
+
const known = state.folders[directory];
|
|
119
|
+
if (known)
|
|
120
|
+
return known;
|
|
121
|
+
const parent = await folderOf(directory);
|
|
122
|
+
const operationId = options.operationId();
|
|
123
|
+
options.assertHeld();
|
|
124
|
+
const id = await options.guard({ action: "folder", path: directory, operationId }, () => target.ensureFolder({
|
|
125
|
+
parent,
|
|
126
|
+
name: posix.basename(directory),
|
|
127
|
+
origin: originOf(principal, root, folder, `${directory}/`),
|
|
128
|
+
}));
|
|
129
|
+
state.folders[directory] = id;
|
|
130
|
+
save();
|
|
131
|
+
return id;
|
|
132
|
+
};
|
|
133
|
+
const create = async (file) => {
|
|
134
|
+
const parent = await folderOf(file.path);
|
|
135
|
+
const operationId = options.operationId();
|
|
136
|
+
const origin = originOf(principal, root, folder, file.path);
|
|
137
|
+
state.pending[file.path] = { origin, operationId };
|
|
138
|
+
save();
|
|
139
|
+
options.assertHeld();
|
|
140
|
+
const pushed = await options.guard({ action: "create", path: file.path, operationId }, () => target.create({
|
|
141
|
+
file,
|
|
142
|
+
name: posix.basename(file.path),
|
|
143
|
+
parent,
|
|
144
|
+
origin,
|
|
145
|
+
operationId,
|
|
146
|
+
...(signal ? { signal } : {}),
|
|
147
|
+
}));
|
|
148
|
+
state.entries[file.path] = { ...pushed, kind: file.kind, hash: file.hash, pushedAt: new Date(now()).toISOString() };
|
|
149
|
+
delete state.pending[file.path];
|
|
150
|
+
save();
|
|
151
|
+
result.created.push(file.path);
|
|
152
|
+
};
|
|
153
|
+
const work = [
|
|
154
|
+
...plan.create.map((file) => ({ file, create: true })),
|
|
155
|
+
...plan.update.map((file) => ({ file, create: false })),
|
|
156
|
+
];
|
|
157
|
+
for (const job of work.sort((a, b) => a.file.path.localeCompare(b.file.path))) {
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
result.stopped = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const { file } = job;
|
|
163
|
+
try {
|
|
164
|
+
const entry = state.entries[file.path];
|
|
165
|
+
if (job.create || !entry) {
|
|
166
|
+
await create(file);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const prepared = await target.prepare({
|
|
170
|
+
id: entry.id,
|
|
171
|
+
file,
|
|
172
|
+
recorded: entry.snapshot,
|
|
173
|
+
force,
|
|
174
|
+
...(signal ? { signal } : {}),
|
|
175
|
+
});
|
|
176
|
+
if ("conflict" in prepared) {
|
|
177
|
+
result.conflicts.push({ path: file.path, url: entry.url, reason: prepared.conflict });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if ("missing" in prepared) {
|
|
181
|
+
if (!force) {
|
|
182
|
+
result.missing.push(file.path);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
delete state.entries[file.path];
|
|
186
|
+
await create(file);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let pushed;
|
|
190
|
+
if ("unchanged" in prepared) {
|
|
191
|
+
pushed = prepared.unchanged;
|
|
192
|
+
result.unchanged++;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const operationId = options.operationId();
|
|
196
|
+
options.assertHeld();
|
|
197
|
+
pushed = await options.guard({ action: "update", path: file.path, operationId }, () => prepared.write());
|
|
198
|
+
result.updated.push(file.path);
|
|
199
|
+
}
|
|
200
|
+
state.entries[file.path] = {
|
|
201
|
+
...pushed,
|
|
202
|
+
kind: file.kind,
|
|
203
|
+
hash: file.hash,
|
|
204
|
+
pushedAt: new Date(now()).toISOString(),
|
|
205
|
+
};
|
|
206
|
+
save();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const failure = options.errorOf(error);
|
|
210
|
+
if (failure.code === "cancelled") {
|
|
211
|
+
result.stopped = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
result.errors.push({ path: file.path, code: failure.code, message: failure.message });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.383.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",
|
|
@@ -182,6 +182,9 @@
|
|
|
182
182
|
"liquidjs": "10.30.0",
|
|
183
183
|
"lossless-json": "4.3.0",
|
|
184
184
|
"mammoth": "1.13.0",
|
|
185
|
+
"mdast-util-from-markdown": "2.1.0",
|
|
186
|
+
"mdast-util-gfm": "3.1.0",
|
|
187
|
+
"micromark-extension-gfm": "3.0.0",
|
|
185
188
|
"ogg-opus-decoder": "1.7.5",
|
|
186
189
|
"psl": "1.15.0",
|
|
187
190
|
"qrcode-generator": "2.0.4",
|
|
@@ -200,6 +203,7 @@
|
|
|
200
203
|
"@cspell/dict-ru_ru": "2.3.3",
|
|
201
204
|
"@modelcontextprotocol/client": "2.2.0",
|
|
202
205
|
"@napi-rs/canvas": "1.0.10",
|
|
206
|
+
"@types/mdast": "4.0.4",
|
|
203
207
|
"@types/node": "^22.20.4",
|
|
204
208
|
"@types/pngjs": "6.0.5",
|
|
205
209
|
"@vitest/coverage-v8": "^5.0.2",
|