feishu-md-sync 0.1.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/LICENSE +21 -0
- package/NOTICE +5 -0
- package/README.md +107 -0
- package/dist/adapters/feishu-adapter.d.ts +44 -0
- package/dist/adapters/feishu-adapter.js +1 -0
- package/dist/adapters/lark-cli-adapter.d.ts +45 -0
- package/dist/adapters/lark-cli-adapter.js +229 -0
- package/dist/cli/commands/core.d.ts +2 -0
- package/dist/cli/commands/core.js +135 -0
- package/dist/cli/commands/publish.d.ts +2 -0
- package/dist/cli/commands/publish.js +63 -0
- package/dist/cli/env.d.ts +27 -0
- package/dist/cli/env.js +113 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +52 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +18 -0
- package/dist/config/sync-config.d.ts +13 -0
- package/dist/config/sync-config.js +60 -0
- package/dist/core/diff.d.ts +1 -0
- package/dist/core/diff.js +20 -0
- package/dist/core/doc-id.d.ts +12 -0
- package/dist/core/doc-id.js +41 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +84 -0
- package/dist/core/markdown-canonical.d.ts +2 -0
- package/dist/core/markdown-canonical.js +11 -0
- package/dist/diff/run-diff.d.ts +23 -0
- package/dist/diff/run-diff.js +33 -0
- package/dist/feishu/types.d.ts +101 -0
- package/dist/feishu/types.js +1 -0
- package/dist/markdown/blocks.d.ts +5 -0
- package/dist/markdown/blocks.js +305 -0
- package/dist/markdown/from-blocks.d.ts +2 -0
- package/dist/markdown/from-blocks.js +110 -0
- package/dist/markdown/links.d.ts +3 -0
- package/dist/markdown/links.js +44 -0
- package/dist/merge/line-merge.d.ts +21 -0
- package/dist/merge/line-merge.js +270 -0
- package/dist/merge/merge-state.d.ts +33 -0
- package/dist/merge/merge-state.js +44 -0
- package/dist/merge/run-merge.d.ts +38 -0
- package/dist/merge/run-merge.js +139 -0
- package/dist/profiles/publish-profile.d.ts +9 -0
- package/dist/profiles/publish-profile.js +9 -0
- package/dist/publish/block-patch-plan.d.ts +34 -0
- package/dist/publish/block-patch-plan.js +223 -0
- package/dist/publish/block-state.d.ts +8 -0
- package/dist/publish/block-state.js +82 -0
- package/dist/publish/block-update.d.ts +4 -0
- package/dist/publish/block-update.js +40 -0
- package/dist/publish/profile-transform.d.ts +5 -0
- package/dist/publish/profile-transform.js +6 -0
- package/dist/publish/publish-plan.d.ts +35 -0
- package/dist/publish/publish-plan.js +113 -0
- package/dist/publish/run-publish.d.ts +25 -0
- package/dist/publish/run-publish.js +299 -0
- package/dist/publish/title.d.ts +9 -0
- package/dist/publish/title.js +20 -0
- package/dist/pull/run-pull.d.ts +23 -0
- package/dist/pull/run-pull.js +57 -0
- package/dist/receipts/publish-receipt.d.ts +49 -0
- package/dist/receipts/publish-receipt.js +52 -0
- package/dist/receipts/pull-receipt.d.ts +31 -0
- package/dist/receipts/pull-receipt.js +30 -0
- package/dist/status/run-status.d.ts +56 -0
- package/dist/status/run-status.js +131 -0
- package/dist/transform/include-tags.d.ts +6 -0
- package/dist/transform/include-tags.js +22 -0
- package/dist/transform/zilliz-publish.d.ts +5 -0
- package/dist/transform/zilliz-publish.js +42 -0
- package/dist/transform/zilliz-pull.d.ts +6 -0
- package/dist/transform/zilliz-pull.js +34 -0
- package/package.json +61 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { markdownToFeishuBlocks } from '../markdown/blocks.js';
|
|
3
|
+
import { feishuBlocksToMarkdown } from '../markdown/from-blocks.js';
|
|
4
|
+
import { hashText, readPublishReceipt, writeLocalBaseSnapshot, writePublishReceipt } from '../receipts/publish-receipt.js';
|
|
5
|
+
import { canonicalMarkdownHash } from '../core/markdown-canonical.js';
|
|
6
|
+
import { findPageBlock, renderableDirectChildBlocks } from './block-state.js';
|
|
7
|
+
import { planPublishBlockPatch } from './block-patch-plan.js';
|
|
8
|
+
import { buildPublishPlan } from './publish-plan.js';
|
|
9
|
+
import { resolvePublishTitle } from './title.js';
|
|
10
|
+
import { applyPublishTransformForProfile } from './profile-transform.js';
|
|
11
|
+
export async function runPublish(input) {
|
|
12
|
+
if (input.write && input.strategy === 'document-replace' && !input.confirmDestructive) {
|
|
13
|
+
throw new Error('document-replace requires --confirm-destructive in non-interactive mode');
|
|
14
|
+
}
|
|
15
|
+
const localSource = await readFile(input.file, 'utf8');
|
|
16
|
+
const transform = applyPublishTransformForProfile(localSource, input.profile);
|
|
17
|
+
if (input.target.kind === 'folder' || (input.target.kind === 'wiki' && input.create)) {
|
|
18
|
+
const title = resolvePublishTitle({
|
|
19
|
+
sourcePath: input.file,
|
|
20
|
+
markdown: transform.markdown
|
|
21
|
+
}).title;
|
|
22
|
+
const plan = buildPublishPlan({
|
|
23
|
+
target: input.target,
|
|
24
|
+
profile: input.profile,
|
|
25
|
+
localSource,
|
|
26
|
+
publishDraft: transform.markdown,
|
|
27
|
+
remoteMarkdown: '',
|
|
28
|
+
receipt: undefined,
|
|
29
|
+
transformWarnings: transform.warnings,
|
|
30
|
+
createDocument: true
|
|
31
|
+
});
|
|
32
|
+
if (!input.write)
|
|
33
|
+
return { mode: 'dry-run', plan };
|
|
34
|
+
const created = await input.adapter.createDocument({
|
|
35
|
+
title,
|
|
36
|
+
markdown: transform.markdown,
|
|
37
|
+
parentToken: input.target.token
|
|
38
|
+
});
|
|
39
|
+
const createdTarget = { kind: 'docx', token: created.documentId };
|
|
40
|
+
const after = await input.adapter.fetchDocMarkdown({ doc: created.documentId });
|
|
41
|
+
const localBaseSnapshot = await writeLocalBaseSnapshot({
|
|
42
|
+
cwd: input.cwd,
|
|
43
|
+
target: createdTarget,
|
|
44
|
+
markdown: localSource
|
|
45
|
+
});
|
|
46
|
+
await writePublishReceipt({
|
|
47
|
+
cwd: input.cwd,
|
|
48
|
+
receipt: {
|
|
49
|
+
version: 1,
|
|
50
|
+
target: createdTarget,
|
|
51
|
+
profile: input.profile,
|
|
52
|
+
localSourceHash: plan.localSourceHash,
|
|
53
|
+
publishDraftHash: plan.publishDraftHash,
|
|
54
|
+
remoteSnapshotHash: hashText(after.markdown),
|
|
55
|
+
remoteRevision: after.revision ?? created.revision,
|
|
56
|
+
localBaseSnapshot,
|
|
57
|
+
updatedAt: new Date().toISOString()
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
mode: 'write',
|
|
62
|
+
plan,
|
|
63
|
+
document: {
|
|
64
|
+
documentId: created.documentId,
|
|
65
|
+
url: created.url
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const remote = await input.adapter.fetchDocMarkdown({ doc: input.target.token });
|
|
70
|
+
const receipt = await readPublishReceipt({ cwd: input.cwd, target: input.target });
|
|
71
|
+
const blockPatchDraftMarkdown = markdownBodyForBlockPatch(transform.markdown, remote.markdown);
|
|
72
|
+
const blockPatch = input.strategy === 'document-replace'
|
|
73
|
+
? undefined
|
|
74
|
+
: await planBlockPatchIfAvailable({
|
|
75
|
+
adapter: input.adapter,
|
|
76
|
+
target: input.target,
|
|
77
|
+
publishDraft: blockPatchDraftMarkdown,
|
|
78
|
+
warnings: transform.warnings
|
|
79
|
+
});
|
|
80
|
+
const plan = buildPublishPlan({
|
|
81
|
+
target: input.target,
|
|
82
|
+
profile: input.profile,
|
|
83
|
+
localSource,
|
|
84
|
+
publishDraft: transform.markdown,
|
|
85
|
+
remoteMarkdown: remote.markdown,
|
|
86
|
+
receipt,
|
|
87
|
+
transformWarnings: transform.warnings,
|
|
88
|
+
blockPatch
|
|
89
|
+
});
|
|
90
|
+
if (!input.write)
|
|
91
|
+
return { mode: 'dry-run', plan };
|
|
92
|
+
if (plan.strategy === 'no-op') {
|
|
93
|
+
await recordPublishReceipt({
|
|
94
|
+
cwd: input.cwd,
|
|
95
|
+
target: input.target,
|
|
96
|
+
profile: input.profile,
|
|
97
|
+
localSource,
|
|
98
|
+
localSourceHash: plan.localSourceHash,
|
|
99
|
+
publishDraftHash: plan.publishDraftHash,
|
|
100
|
+
remoteMarkdown: remote.markdown,
|
|
101
|
+
remoteRevision: remote.revision
|
|
102
|
+
});
|
|
103
|
+
return { mode: 'write', plan };
|
|
104
|
+
}
|
|
105
|
+
if (plan.strategy === 'block-patch') {
|
|
106
|
+
if (input.strategy !== 'auto' && input.strategy !== 'block-patch') {
|
|
107
|
+
throw new Error('block-patch requires --strategy auto or --strategy block-patch');
|
|
108
|
+
}
|
|
109
|
+
if (plan.requiresUntrackedRemoteConfirmation && !input.confirmUntrackedRemote) {
|
|
110
|
+
throw new Error('block-patch for an untracked remote requires --confirm-untracked-remote');
|
|
111
|
+
}
|
|
112
|
+
if (plan.requiresCollaborationRiskConfirmation && !input.confirmCollaborationRisk) {
|
|
113
|
+
throw new Error('block-patch replacing or deleting existing blocks requires --confirm-collaboration-risk');
|
|
114
|
+
}
|
|
115
|
+
await applyBlockPatch({
|
|
116
|
+
adapter: input.adapter,
|
|
117
|
+
doc: input.target.token,
|
|
118
|
+
plan,
|
|
119
|
+
desiredBlocks: markdownToFeishuBlocks(blockPatchDraftMarkdown)
|
|
120
|
+
});
|
|
121
|
+
const after = await input.adapter.fetchDocMarkdown({ doc: input.target.token });
|
|
122
|
+
if (canonicalMarkdownHash(after.markdown) !== canonicalMarkdownHash(transform.markdown)) {
|
|
123
|
+
throw new Error('block-patch readback verification failed: remote Markdown differs from publish draft');
|
|
124
|
+
}
|
|
125
|
+
await recordPublishReceipt({
|
|
126
|
+
cwd: input.cwd,
|
|
127
|
+
target: input.target,
|
|
128
|
+
profile: input.profile,
|
|
129
|
+
localSource,
|
|
130
|
+
localSourceHash: plan.localSourceHash,
|
|
131
|
+
publishDraftHash: plan.publishDraftHash,
|
|
132
|
+
remoteMarkdown: after.markdown,
|
|
133
|
+
remoteRevision: after.revision
|
|
134
|
+
});
|
|
135
|
+
return { mode: 'write', plan };
|
|
136
|
+
}
|
|
137
|
+
if (plan.strategy === 'document-replace') {
|
|
138
|
+
if (plan.remoteChanged && input.strategy !== 'document-replace') {
|
|
139
|
+
throw new Error('remote changed since last publish receipt; refusing to write without --strategy document-replace --confirm-destructive');
|
|
140
|
+
}
|
|
141
|
+
if (input.strategy !== 'document-replace') {
|
|
142
|
+
throw new Error('document-replace requires --strategy document-replace');
|
|
143
|
+
}
|
|
144
|
+
if (!input.confirmDestructive) {
|
|
145
|
+
throw new Error('document-replace requires --confirm-destructive in non-interactive mode');
|
|
146
|
+
}
|
|
147
|
+
await input.adapter.replaceDocument({ doc: input.target.token, markdown: transform.markdown });
|
|
148
|
+
const after = await input.adapter.fetchDocMarkdown({ doc: input.target.token });
|
|
149
|
+
await recordPublishReceipt({
|
|
150
|
+
cwd: input.cwd,
|
|
151
|
+
target: input.target,
|
|
152
|
+
profile: input.profile,
|
|
153
|
+
localSource,
|
|
154
|
+
localSourceHash: plan.localSourceHash,
|
|
155
|
+
publishDraftHash: plan.publishDraftHash,
|
|
156
|
+
remoteMarkdown: after.markdown,
|
|
157
|
+
remoteRevision: after.revision
|
|
158
|
+
});
|
|
159
|
+
return { mode: 'write', plan };
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`Write strategy ${plan.strategy} is not implemented in the first slice.`);
|
|
162
|
+
}
|
|
163
|
+
async function recordPublishReceipt(input) {
|
|
164
|
+
const localBaseSnapshot = await writeLocalBaseSnapshot({
|
|
165
|
+
cwd: input.cwd,
|
|
166
|
+
target: input.target,
|
|
167
|
+
markdown: input.localSource
|
|
168
|
+
});
|
|
169
|
+
await writePublishReceipt({
|
|
170
|
+
cwd: input.cwd,
|
|
171
|
+
receipt: {
|
|
172
|
+
version: 1,
|
|
173
|
+
target: input.target,
|
|
174
|
+
profile: input.profile,
|
|
175
|
+
localSourceHash: input.localSourceHash,
|
|
176
|
+
publishDraftHash: input.publishDraftHash,
|
|
177
|
+
remoteSnapshotHash: hashText(input.remoteMarkdown),
|
|
178
|
+
remoteRevision: input.remoteRevision,
|
|
179
|
+
localBaseSnapshot,
|
|
180
|
+
updatedAt: new Date().toISOString()
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function applyBlockPatch(input) {
|
|
185
|
+
if (!input.plan.blockPatch) {
|
|
186
|
+
throw new Error('block-patch write is missing a block patch plan');
|
|
187
|
+
}
|
|
188
|
+
if (!input.adapter.replaceBlock || !input.adapter.insertBlocksAfter || !input.adapter.deleteBlocks) {
|
|
189
|
+
throw new Error('Configured Feishu adapter does not support block-patch writes');
|
|
190
|
+
}
|
|
191
|
+
for (const operation of input.plan.blockPatch.operations) {
|
|
192
|
+
if (operation.kind === 'update') {
|
|
193
|
+
const block = blockAtPath(input.desiredBlocks, operation.path);
|
|
194
|
+
if (!block)
|
|
195
|
+
throw new Error(`block-patch update missing desired block at ${operation.path.join('.')}`);
|
|
196
|
+
await input.adapter.replaceBlock({
|
|
197
|
+
doc: input.doc,
|
|
198
|
+
blockId: operation.remoteBlockId,
|
|
199
|
+
markdown: markdownForWritableBlocks([block])
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (operation.kind === 'create') {
|
|
204
|
+
await input.adapter.insertBlocksAfter({
|
|
205
|
+
doc: input.doc,
|
|
206
|
+
blockId: operation.insertAfterBlockId,
|
|
207
|
+
markdown: markdownForWritableBlocks(operation.blocks)
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
await input.adapter.deleteBlocks({
|
|
212
|
+
doc: input.doc,
|
|
213
|
+
blockIds: operation.blockIds
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function blockAtPath(blocks, path) {
|
|
218
|
+
let currentBlocks = blocks;
|
|
219
|
+
let current;
|
|
220
|
+
for (const index of path) {
|
|
221
|
+
current = currentBlocks[index];
|
|
222
|
+
if (!current)
|
|
223
|
+
return undefined;
|
|
224
|
+
currentBlocks = Array.isArray(current.children) && current.children.every(isFeishuBlock)
|
|
225
|
+
? current.children
|
|
226
|
+
: [];
|
|
227
|
+
}
|
|
228
|
+
return current;
|
|
229
|
+
}
|
|
230
|
+
function markdownForWritableBlocks(blocks) {
|
|
231
|
+
for (const block of blocks) {
|
|
232
|
+
assertWritableMarkdownBlock(block);
|
|
233
|
+
}
|
|
234
|
+
return feishuBlocksToMarkdown(blocks).trim();
|
|
235
|
+
}
|
|
236
|
+
function assertWritableMarkdownBlock(block) {
|
|
237
|
+
if (!isWritableMarkdownBlockType(block.block_type)) {
|
|
238
|
+
throw new Error(`block-patch write does not support block_type ${block.block_type}`);
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(block.children) && block.children.length > 0) {
|
|
241
|
+
throw new Error(`block-patch write does not support nested children for block_type ${block.block_type}`);
|
|
242
|
+
}
|
|
243
|
+
if (block.block_type === 31) {
|
|
244
|
+
const cells = block.table?.cells ?? [];
|
|
245
|
+
if (!cells.every(isSimpleTableCellBlock)) {
|
|
246
|
+
throw new Error('block-patch write only supports simple Markdown table cells');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function isWritableMarkdownBlockType(blockType) {
|
|
251
|
+
return blockType === 2 || (blockType >= 3 && blockType <= 8) || blockType === 12 || blockType === 13 || blockType === 14 || blockType === 31;
|
|
252
|
+
}
|
|
253
|
+
function isSimpleTableCellBlock(value) {
|
|
254
|
+
if (!isFeishuBlock(value))
|
|
255
|
+
return false;
|
|
256
|
+
return value.block_type === 2 && (!Array.isArray(value.children) || value.children.length === 0);
|
|
257
|
+
}
|
|
258
|
+
function markdownBodyForBlockPatch(publishDraft, remoteMarkdown) {
|
|
259
|
+
const publishTitle = leadingH1Title(publishDraft);
|
|
260
|
+
if (!publishTitle)
|
|
261
|
+
return publishDraft;
|
|
262
|
+
const remoteTitle = leadingH1Title(remoteMarkdown);
|
|
263
|
+
if (remoteTitle !== publishTitle)
|
|
264
|
+
return publishDraft;
|
|
265
|
+
return stripLeadingH1(publishDraft);
|
|
266
|
+
}
|
|
267
|
+
function leadingH1Title(markdown) {
|
|
268
|
+
const normalized = markdown.replace(/\r\n/g, '\n').trimStart();
|
|
269
|
+
const match = normalized.match(/^#\s+(.+?)(?:\n|$)/);
|
|
270
|
+
return match?.[1]?.trim();
|
|
271
|
+
}
|
|
272
|
+
function stripLeadingH1(markdown) {
|
|
273
|
+
return markdown
|
|
274
|
+
.replace(/\r\n/g, '\n')
|
|
275
|
+
.trimStart()
|
|
276
|
+
.replace(/^#\s+.+?(?:\n{1,2}|$)/, '')
|
|
277
|
+
.trimStart();
|
|
278
|
+
}
|
|
279
|
+
async function planBlockPatchIfAvailable(input) {
|
|
280
|
+
if (input.target.kind !== 'docx' || !input.adapter.fetchDocBlocks) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const remote = await input.adapter.fetchDocBlocks({ doc: input.target.token });
|
|
285
|
+
const pageBlock = findPageBlock(remote.blocks, input.target.token);
|
|
286
|
+
return planPublishBlockPatch({
|
|
287
|
+
parentBlockId: pageBlock.block_id,
|
|
288
|
+
remoteBlocks: renderableDirectChildBlocks(remote.blocks, pageBlock),
|
|
289
|
+
desiredBlocks: markdownToFeishuBlocks(input.publishDraft)
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
input.warnings.push(`block-patch planning unavailable: ${error.message}`);
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function isFeishuBlock(value) {
|
|
298
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && 'block_type' in value);
|
|
299
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export function resolvePublishTitle(input) {
|
|
3
|
+
const explicit = clean(input.title);
|
|
4
|
+
if (explicit)
|
|
5
|
+
return { title: explicit, titleSource: 'option' };
|
|
6
|
+
const firstH1 = input.markdown
|
|
7
|
+
.split(/\r?\n/)
|
|
8
|
+
.map((line) => line.match(/^#\s+(.+?)\s*$/)?.[1]?.trim())
|
|
9
|
+
.find((title) => title);
|
|
10
|
+
if (firstH1)
|
|
11
|
+
return { title: firstH1, titleSource: 'first H1' };
|
|
12
|
+
return {
|
|
13
|
+
title: path.basename(input.sourcePath, path.extname(input.sourcePath)) || 'Untitled',
|
|
14
|
+
titleSource: 'file basename'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function clean(value) {
|
|
18
|
+
const trimmed = value?.trim();
|
|
19
|
+
return trimmed ? trimmed : undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FeishuAdapter } from '../adapters/feishu-adapter.js';
|
|
2
|
+
import type { PublishProfileName } from '../profiles/publish-profile.js';
|
|
3
|
+
import { type PublishReceiptTarget } from '../receipts/publish-receipt.js';
|
|
4
|
+
export type RunPullResult = {
|
|
5
|
+
mode: 'write';
|
|
6
|
+
target: PublishReceiptTarget;
|
|
7
|
+
outputPath: string;
|
|
8
|
+
profile: PublishProfileName;
|
|
9
|
+
remoteRevision?: string;
|
|
10
|
+
remoteRawHash: string;
|
|
11
|
+
outputHash: string;
|
|
12
|
+
receiptPath?: string;
|
|
13
|
+
warnings: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare function runPull(input: {
|
|
16
|
+
cwd: string;
|
|
17
|
+
target: PublishReceiptTarget;
|
|
18
|
+
outputPath: string;
|
|
19
|
+
profile: PublishProfileName;
|
|
20
|
+
overwrite: boolean;
|
|
21
|
+
writeReceipt: boolean;
|
|
22
|
+
adapter: FeishuAdapter;
|
|
23
|
+
}): Promise<RunPullResult>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { normalizeReceiptOutputPath, pullReceiptPath, writePullReceipt } from '../receipts/pull-receipt.js';
|
|
3
|
+
import { hashText } from '../receipts/publish-receipt.js';
|
|
4
|
+
import { applyPullTransformForProfile } from '../transform/zilliz-pull.js';
|
|
5
|
+
export async function runPull(input) {
|
|
6
|
+
await assertPullOutputWritable(input.outputPath, input.overwrite);
|
|
7
|
+
const remote = await input.adapter.fetchDocMarkdown({ doc: input.target.token });
|
|
8
|
+
const transform = applyPullTransformForProfile(remote.markdown, input.profile);
|
|
9
|
+
await writeFile(input.outputPath, transform.markdown, 'utf8');
|
|
10
|
+
const written = await readFile(input.outputPath, 'utf8');
|
|
11
|
+
const outputHash = hashText(transform.markdown);
|
|
12
|
+
const writtenHash = hashText(written);
|
|
13
|
+
if (writtenHash !== outputHash) {
|
|
14
|
+
throw new Error(`pull local write verification failed: expected ${outputHash}, got ${writtenHash}`);
|
|
15
|
+
}
|
|
16
|
+
const remoteRawHash = hashText(remote.markdown);
|
|
17
|
+
const result = {
|
|
18
|
+
mode: 'write',
|
|
19
|
+
target: input.target,
|
|
20
|
+
outputPath: input.outputPath,
|
|
21
|
+
profile: input.profile,
|
|
22
|
+
remoteRevision: remote.revision,
|
|
23
|
+
remoteRawHash,
|
|
24
|
+
outputHash,
|
|
25
|
+
warnings: transform.warnings
|
|
26
|
+
};
|
|
27
|
+
if (input.writeReceipt) {
|
|
28
|
+
const receipt = {
|
|
29
|
+
version: 1,
|
|
30
|
+
kind: 'pull-snapshot',
|
|
31
|
+
target: input.target,
|
|
32
|
+
outputPath: normalizeReceiptOutputPath({ cwd: input.cwd, outputPath: input.outputPath }),
|
|
33
|
+
profile: input.profile,
|
|
34
|
+
remoteRevision: remote.revision,
|
|
35
|
+
remoteRawHash,
|
|
36
|
+
outputHash,
|
|
37
|
+
pulledAt: new Date().toISOString()
|
|
38
|
+
};
|
|
39
|
+
await writePullReceipt({ cwd: input.cwd, receipt });
|
|
40
|
+
result.receiptPath = pullReceiptPath({ cwd: input.cwd, outputPath: receipt.outputPath, target: input.target });
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
async function assertPullOutputWritable(outputPath, overwrite) {
|
|
45
|
+
try {
|
|
46
|
+
await readFile(outputPath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT')
|
|
50
|
+
return;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (!overwrite) {
|
|
54
|
+
throw new Error(`Refusing to overwrite existing output without --overwrite: ${outputPath}\n` +
|
|
55
|
+
'Pull writes remote snapshots only; choose a new *.remote.md output or rerun with --overwrite after review.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PublishProfileName } from '../profiles/publish-profile.js';
|
|
2
|
+
export type PublishReceiptTarget = {
|
|
3
|
+
kind: 'docx' | 'wiki' | 'folder';
|
|
4
|
+
token: string;
|
|
5
|
+
};
|
|
6
|
+
export type LocalBaseSnapshot = {
|
|
7
|
+
path: string;
|
|
8
|
+
hash: string;
|
|
9
|
+
};
|
|
10
|
+
export type PublishReceipt = {
|
|
11
|
+
version: 1;
|
|
12
|
+
target: PublishReceiptTarget;
|
|
13
|
+
profile: PublishProfileName;
|
|
14
|
+
localSourceHash: string;
|
|
15
|
+
publishDraftHash: string;
|
|
16
|
+
remoteSnapshotHash: string;
|
|
17
|
+
remoteRevision?: string;
|
|
18
|
+
localBaseSnapshot?: LocalBaseSnapshot;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function hashText(value: string): string;
|
|
22
|
+
export declare function publishReceiptPath(input: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
target: PublishReceiptTarget;
|
|
25
|
+
}): string;
|
|
26
|
+
export declare function baseSnapshotPath(input: {
|
|
27
|
+
cwd: string;
|
|
28
|
+
target: PublishReceiptTarget;
|
|
29
|
+
}): string;
|
|
30
|
+
export declare function baseSnapshotRelativePath(input: {
|
|
31
|
+
target: PublishReceiptTarget;
|
|
32
|
+
}): string;
|
|
33
|
+
export declare function readPublishReceipt(input: {
|
|
34
|
+
cwd: string;
|
|
35
|
+
target: PublishReceiptTarget;
|
|
36
|
+
}): Promise<PublishReceipt | undefined>;
|
|
37
|
+
export declare function writePublishReceipt(input: {
|
|
38
|
+
cwd: string;
|
|
39
|
+
receipt: PublishReceipt;
|
|
40
|
+
}): Promise<void>;
|
|
41
|
+
export declare function writeLocalBaseSnapshot(input: {
|
|
42
|
+
cwd: string;
|
|
43
|
+
target: PublishReceiptTarget;
|
|
44
|
+
markdown: string;
|
|
45
|
+
}): Promise<LocalBaseSnapshot>;
|
|
46
|
+
export declare function readLocalBaseSnapshot(input: {
|
|
47
|
+
cwd: string;
|
|
48
|
+
snapshot: LocalBaseSnapshot;
|
|
49
|
+
}): Promise<string | undefined>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export function hashText(value) {
|
|
5
|
+
return createHash('sha256').update(value).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
export function publishReceiptPath(input) {
|
|
8
|
+
return join(input.cwd, '.sync', 'feishu-md-sync', `${input.target.kind}-${input.target.token}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function baseSnapshotPath(input) {
|
|
11
|
+
return join(input.cwd, baseSnapshotRelativePath({ target: input.target }));
|
|
12
|
+
}
|
|
13
|
+
export function baseSnapshotRelativePath(input) {
|
|
14
|
+
return join('.sync', 'feishu-md-sync', 'bases', `${input.target.kind}-${input.target.token}-local.md`);
|
|
15
|
+
}
|
|
16
|
+
export async function readPublishReceipt(input) {
|
|
17
|
+
const path = publishReceiptPath(input);
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await readFile(path, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT')
|
|
24
|
+
return undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
export async function writePublishReceipt(input) {
|
|
30
|
+
const path = publishReceiptPath({ cwd: input.cwd, target: input.receipt.target });
|
|
31
|
+
await mkdir(dirname(path), { recursive: true });
|
|
32
|
+
await writeFile(path, `${JSON.stringify(input.receipt, null, 2)}\n`, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
export async function writeLocalBaseSnapshot(input) {
|
|
35
|
+
const path = baseSnapshotPath({ cwd: input.cwd, target: input.target });
|
|
36
|
+
await mkdir(dirname(path), { recursive: true });
|
|
37
|
+
await writeFile(path, input.markdown, 'utf8');
|
|
38
|
+
return {
|
|
39
|
+
path: baseSnapshotRelativePath({ target: input.target }),
|
|
40
|
+
hash: hashText(input.markdown)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function readLocalBaseSnapshot(input) {
|
|
44
|
+
try {
|
|
45
|
+
return await readFile(join(input.cwd, input.snapshot.path), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
return undefined;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PublishProfileName } from '../profiles/publish-profile.js';
|
|
2
|
+
import { type PublishReceiptTarget } from './publish-receipt.js';
|
|
3
|
+
export type PullReceipt = {
|
|
4
|
+
version: 1;
|
|
5
|
+
kind: 'pull-snapshot';
|
|
6
|
+
target: PublishReceiptTarget;
|
|
7
|
+
outputPath: string;
|
|
8
|
+
profile: PublishProfileName;
|
|
9
|
+
remoteRevision?: string;
|
|
10
|
+
remoteRawHash: string;
|
|
11
|
+
outputHash: string;
|
|
12
|
+
pulledAt: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function pullReceiptPath(input: {
|
|
15
|
+
cwd: string;
|
|
16
|
+
outputPath: string;
|
|
17
|
+
target: PublishReceiptTarget;
|
|
18
|
+
}): string;
|
|
19
|
+
export declare function normalizeReceiptOutputPath(input: {
|
|
20
|
+
cwd: string;
|
|
21
|
+
outputPath: string;
|
|
22
|
+
}): string;
|
|
23
|
+
export declare function readPullReceipt(input: {
|
|
24
|
+
cwd: string;
|
|
25
|
+
outputPath: string;
|
|
26
|
+
target: PublishReceiptTarget;
|
|
27
|
+
}): Promise<PullReceipt | undefined>;
|
|
28
|
+
export declare function writePullReceipt(input: {
|
|
29
|
+
cwd: string;
|
|
30
|
+
receipt: PullReceipt;
|
|
31
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { hashText } from './publish-receipt.js';
|
|
4
|
+
export function pullReceiptPath(input) {
|
|
5
|
+
const outputKey = hashText(resolve(input.cwd, input.outputPath)).slice(0, 16);
|
|
6
|
+
return join(input.cwd, '.sync', 'feishu-md-sync', 'pulls', `${outputKey}-${input.target.kind}-${input.target.token}.json`);
|
|
7
|
+
}
|
|
8
|
+
export function normalizeReceiptOutputPath(input) {
|
|
9
|
+
const absolute = resolve(input.cwd, input.outputPath);
|
|
10
|
+
const relativePath = relative(input.cwd, absolute);
|
|
11
|
+
return relativePath.startsWith('..') ? absolute : relativePath;
|
|
12
|
+
}
|
|
13
|
+
export async function readPullReceipt(input) {
|
|
14
|
+
const path = pullReceiptPath(input);
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = await readFile(path, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT')
|
|
21
|
+
return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
export async function writePullReceipt(input) {
|
|
27
|
+
const path = pullReceiptPath({ cwd: input.cwd, outputPath: input.receipt.outputPath, target: input.receipt.target });
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
await writeFile(path, `${JSON.stringify(input.receipt, null, 2)}\n`, 'utf8');
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FeishuAdapter } from '../adapters/feishu-adapter.js';
|
|
2
|
+
import type { PublishProfileName } from '../profiles/publish-profile.js';
|
|
3
|
+
import { type PublishReceipt, type PublishReceiptTarget } from '../receipts/publish-receipt.js';
|
|
4
|
+
export type PublishStatusState = 'untracked' | 'clean' | 'local-changed' | 'remote-changed' | 'diverged';
|
|
5
|
+
export type PublishStatusRecommendationAction = 'no-action' | 'publish-dry-run' | 'pull-review' | 'resolve-divergence' | 'adopt-or-replace';
|
|
6
|
+
export type PublishStatusResult = {
|
|
7
|
+
target: PublishReceiptTarget;
|
|
8
|
+
sourcePath: string;
|
|
9
|
+
profile: PublishProfileName;
|
|
10
|
+
state: PublishStatusState;
|
|
11
|
+
localChanged: boolean;
|
|
12
|
+
remoteChanged: boolean;
|
|
13
|
+
contentMatchesRemote: boolean;
|
|
14
|
+
hasReceipt: boolean;
|
|
15
|
+
receiptPath: string;
|
|
16
|
+
localSourceHash: string;
|
|
17
|
+
publishDraftHash: string;
|
|
18
|
+
publishDraftCanonicalHash: string;
|
|
19
|
+
remoteSnapshotHash: string;
|
|
20
|
+
remoteCanonicalHash: string;
|
|
21
|
+
remoteRevision?: string;
|
|
22
|
+
transformWarnings: string[];
|
|
23
|
+
recommendation: {
|
|
24
|
+
action: PublishStatusRecommendationAction;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export type PublishStatusContext = {
|
|
29
|
+
cwd: string;
|
|
30
|
+
target: PublishReceiptTarget;
|
|
31
|
+
sourcePath: string;
|
|
32
|
+
profile: PublishProfileName;
|
|
33
|
+
localSource: string;
|
|
34
|
+
publishDraft: string;
|
|
35
|
+
publishDraftCanonical: string;
|
|
36
|
+
remoteMarkdown: string;
|
|
37
|
+
remoteCanonical: string;
|
|
38
|
+
remoteRevision?: string;
|
|
39
|
+
receipt: PublishReceipt | undefined;
|
|
40
|
+
transformWarnings: string[];
|
|
41
|
+
};
|
|
42
|
+
export declare function runStatus(input: {
|
|
43
|
+
cwd: string;
|
|
44
|
+
sourcePath: string;
|
|
45
|
+
target: PublishReceiptTarget;
|
|
46
|
+
profile: PublishProfileName;
|
|
47
|
+
adapter: FeishuAdapter;
|
|
48
|
+
}): Promise<PublishStatusResult>;
|
|
49
|
+
export declare function loadPublishStatusContext(input: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
sourcePath: string;
|
|
52
|
+
target: PublishReceiptTarget;
|
|
53
|
+
profile: PublishProfileName;
|
|
54
|
+
adapter: FeishuAdapter;
|
|
55
|
+
}): Promise<PublishStatusContext>;
|
|
56
|
+
export declare function statusFromContext(context: PublishStatusContext): PublishStatusResult;
|