@wirecat/cli-core 0.384.0 → 0.386.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 CHANGED
@@ -7,6 +7,33 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.386.0 — 22.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Document sync works both ways.** `pushDocuments` reads every mirrored file back on each run and merges changes
15
+ made in the provider into the local file. Only the lines of changed blocks are rewritten, and a tick is flipped in
16
+ place. A conflict lists the changed parts and when each side changed, and the run goes on with the other files;
17
+ `keep: "local" | "remote" | "both"` settles conflicts. New: `mergeBlocks`, `spliceMarkdown`, `blocksMarkdown`,
18
+ `inlineMarkdown`, `placedMarkdownBlocks`.
19
+
20
+ ### Changed — may break callers
21
+
22
+ - `PushOptions.force` is replaced by `keep` (settle conflicts) and `recreate` (create a remotely deleted file again);
23
+ `prepare` gets `keep` instead of `force`. Its update result is `{ local?, afterLocal?, remote, write }`:
24
+ `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`,
25
+ `remoteChangedAt` and `remoteChangedBy`. `reconcileBlocks` is removed; `mergeBlocks` replaces it.
26
+
27
+ ## 0.385.0 — 22.10.2026
28
+
29
+ ### Fixed
30
+
31
+ - **A store the pre-rename `cli-messaging` wrote is refused again, and left untouched.** Its `schema_migrations`
32
+ uses its own, higher numbers ("29, needs at least 28"); since store version 28 the version check took it for a
33
+ compatible file, opened it and failed on a missing column with `internal_error`, and a later version would have
34
+ migrated it. A file that has a version but no `meetings` table, which version 1 here always creates, now fails with
35
+ `configuration_error` (exit 3) before anything runs. `migrate` takes `ownTable: null` for synthetic test schemas.
36
+
10
37
  ## 0.384.0 — 22.10.2026
11
38
 
12
39
  ### Added
@@ -1,14 +1,15 @@
1
- export { type BlockHunk, diffBlocks, type Reconciled, reconcileBlocks } from "../services/documents/block-diff.js";
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";
@@ -1,11 +1,12 @@
1
- export { diffBlocks, reconcileBlocks } from "../services/documents/block-diff.js";
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 withImages = (block) => {
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
- if (block?.kind === "paragraph" && !block.inlines.length)
70
- return found;
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.flatMap((child) => child.type === "paragraph" || child.type === "heading"
84
- ? withImages({ kind: "quote", inlines: trimmed(inlinesOf(child.children)) })
85
- : blocks([child], depth));
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 ? withImages({ kind: "paragraph", inlines: trimmed(textOf(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 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.
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
- /** Reads the remote and compares it with `recorded`; a conflict instead of a write unless `force`. */
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
- force: boolean;
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
- export type Reconciled = {
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
- ticksKept: number;
20
+ conflicts: BlockConflict[];
14
21
  } | {
15
22
  status: "conflict";
23
+ conflicts: BlockConflict[];
24
+ } | {
25
+ status: "unaligned";
16
26
  };
17
27
  /**
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.
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 reconcileBlocks: (recorded: Block[], current: Block[], local: Block[], lastLocal: Block[]) => Reconciled;
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, blockKey), [base.length, next.length]]) {
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
- * 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.
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 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))
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
- return { status: "clean", blocks, ticksKept };
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(`![${escapeText(block.alt)}](${linkTarget(block.url)})`);
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
- force?: boolean;
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, force = false, signal } = options;
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
- unchanged: plan.unchanged.length,
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
- force,
197
+ ...(keep ? { keep } : {}),
174
198
  ...(signal ? { signal } : {}),
175
199
  });
176
200
  if ("conflict" in prepared) {
177
- result.conflicts.push({ path: file.path, url: entry.url, reason: prepared.conflict });
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 (!force) {
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
- 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);
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: file.hash,
257
+ hash,
204
258
  pushedAt: new Date(now()).toISOString(),
205
259
  };
206
260
  save();
@@ -24,8 +24,9 @@ export declare const MIGRATIONS: Migration[];
24
24
  * that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
25
25
  * the file. An app without that command (drive) passes no `command`, and the advice names none.
26
26
  */
27
- export declare const migrate: (database: CacheDatabase, { migrations, now, command, }?: {
27
+ export declare const migrate: (database: CacheDatabase, { migrations, now, command, ownTable, }?: {
28
28
  migrations?: Migration[];
29
29
  now?: () => number;
30
30
  command?: string;
31
+ ownTable?: string | null;
31
32
  }) => void;
@@ -17,6 +17,13 @@ const HISTORY = `CREATE TABLE IF NOT EXISTS schema_migrations (
17
17
  applied_at INTEGER NOT NULL
18
18
  )`;
19
19
  const latest = (migrations) => migrations.at(-1)?.version ?? 0;
20
+ /**
21
+ * The pre-rename `cli-messaging` store also has `schema_migrations`, with its own, higher numbers ("29, needs at
22
+ * least 28"). Once ours caught up, the version check alone took it for a compatible file, opened it and would have
23
+ * migrated it. Version 1 here always creates `meetings`; that store never had one.
24
+ */
25
+ const OWN_TABLE = "meetings";
26
+ const hasTable = (database, name) => database.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined;
20
27
  const fileState = (database) => {
21
28
  const row = database
22
29
  .prepare("SELECT version, min_compatible FROM schema_migrations ORDER BY version DESC LIMIT 1")
@@ -29,12 +36,16 @@ const fileState = (database) => {
29
36
  * that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
30
37
  * the file. An app without that command (drive) passes no `command`, and the advice names none.
31
38
  */
32
- export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, } = {}) => {
39
+ export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, ownTable = OWN_TABLE, } = {}) => {
33
40
  const ours = latest(migrations);
34
41
  database.exec("BEGIN IMMEDIATE");
35
42
  try {
36
43
  database.exec(HISTORY);
37
44
  const file = fileState(database);
45
+ if (file.version > 0 && ownTable !== null && !hasTable(database, ownTable)) {
46
+ throw new CliError("configuration_error", `the message store was written by another, incompatible tool (schema ${file.version} by its own numbering, ` +
47
+ "with no meetings table) — this tool will not open or change it; move it aside to start a new store");
48
+ }
38
49
  if (file.version > ours && file.minCompatible > ours) {
39
50
  throw new CliError("configuration_error", `the message store was written by a newer version (schema ${file.version}, needs at least ` +
40
51
  `${file.minCompatible}; this one speaks ${ours}) — upgrade this tool`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.384.0",
3
+ "version": "0.386.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",