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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +5 -0
  3. package/README.md +107 -0
  4. package/dist/adapters/feishu-adapter.d.ts +44 -0
  5. package/dist/adapters/feishu-adapter.js +1 -0
  6. package/dist/adapters/lark-cli-adapter.d.ts +45 -0
  7. package/dist/adapters/lark-cli-adapter.js +229 -0
  8. package/dist/cli/commands/core.d.ts +2 -0
  9. package/dist/cli/commands/core.js +135 -0
  10. package/dist/cli/commands/publish.d.ts +2 -0
  11. package/dist/cli/commands/publish.js +63 -0
  12. package/dist/cli/env.d.ts +27 -0
  13. package/dist/cli/env.js +113 -0
  14. package/dist/cli/index.d.ts +2 -0
  15. package/dist/cli/index.js +52 -0
  16. package/dist/cli/output.d.ts +4 -0
  17. package/dist/cli/output.js +18 -0
  18. package/dist/config/sync-config.d.ts +13 -0
  19. package/dist/config/sync-config.js +60 -0
  20. package/dist/core/diff.d.ts +1 -0
  21. package/dist/core/diff.js +20 -0
  22. package/dist/core/doc-id.d.ts +12 -0
  23. package/dist/core/doc-id.js +41 -0
  24. package/dist/core/hash.d.ts +6 -0
  25. package/dist/core/hash.js +84 -0
  26. package/dist/core/markdown-canonical.d.ts +2 -0
  27. package/dist/core/markdown-canonical.js +11 -0
  28. package/dist/diff/run-diff.d.ts +23 -0
  29. package/dist/diff/run-diff.js +33 -0
  30. package/dist/feishu/types.d.ts +101 -0
  31. package/dist/feishu/types.js +1 -0
  32. package/dist/markdown/blocks.d.ts +5 -0
  33. package/dist/markdown/blocks.js +305 -0
  34. package/dist/markdown/from-blocks.d.ts +2 -0
  35. package/dist/markdown/from-blocks.js +110 -0
  36. package/dist/markdown/links.d.ts +3 -0
  37. package/dist/markdown/links.js +44 -0
  38. package/dist/merge/line-merge.d.ts +21 -0
  39. package/dist/merge/line-merge.js +270 -0
  40. package/dist/merge/merge-state.d.ts +33 -0
  41. package/dist/merge/merge-state.js +44 -0
  42. package/dist/merge/run-merge.d.ts +38 -0
  43. package/dist/merge/run-merge.js +139 -0
  44. package/dist/profiles/publish-profile.d.ts +9 -0
  45. package/dist/profiles/publish-profile.js +9 -0
  46. package/dist/publish/block-patch-plan.d.ts +34 -0
  47. package/dist/publish/block-patch-plan.js +223 -0
  48. package/dist/publish/block-state.d.ts +8 -0
  49. package/dist/publish/block-state.js +82 -0
  50. package/dist/publish/block-update.d.ts +4 -0
  51. package/dist/publish/block-update.js +40 -0
  52. package/dist/publish/profile-transform.d.ts +5 -0
  53. package/dist/publish/profile-transform.js +6 -0
  54. package/dist/publish/publish-plan.d.ts +35 -0
  55. package/dist/publish/publish-plan.js +113 -0
  56. package/dist/publish/run-publish.d.ts +25 -0
  57. package/dist/publish/run-publish.js +299 -0
  58. package/dist/publish/title.d.ts +9 -0
  59. package/dist/publish/title.js +20 -0
  60. package/dist/pull/run-pull.d.ts +23 -0
  61. package/dist/pull/run-pull.js +57 -0
  62. package/dist/receipts/publish-receipt.d.ts +49 -0
  63. package/dist/receipts/publish-receipt.js +52 -0
  64. package/dist/receipts/pull-receipt.d.ts +31 -0
  65. package/dist/receipts/pull-receipt.js +30 -0
  66. package/dist/status/run-status.d.ts +56 -0
  67. package/dist/status/run-status.js +131 -0
  68. package/dist/transform/include-tags.d.ts +6 -0
  69. package/dist/transform/include-tags.js +22 -0
  70. package/dist/transform/zilliz-publish.d.ts +5 -0
  71. package/dist/transform/zilliz-publish.js +42 -0
  72. package/dist/transform/zilliz-pull.d.ts +6 -0
  73. package/dist/transform/zilliz-pull.js +34 -0
  74. 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,9 @@
1
+ export type PublishTitleSource = 'option' | 'first H1' | 'file basename';
2
+ export declare function resolvePublishTitle(input: {
3
+ sourcePath: string;
4
+ markdown: string;
5
+ title?: string;
6
+ }): {
7
+ title: string;
8
+ titleSource: PublishTitleSource;
9
+ };
@@ -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;