@iwonz/ywtc 4.0.0 → 4.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 (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +47 -45
  3. package/dist/adapters/confluence.js +19 -4
  4. package/dist/adapters/confluence.js.map +1 -1
  5. package/dist/adapters/yandex.d.ts +7 -8
  6. package/dist/adapters/yandex.js +68 -22
  7. package/dist/adapters/yandex.js.map +1 -1
  8. package/dist/cli-arguments.d.ts +3 -2
  9. package/dist/cli-arguments.js +42 -37
  10. package/dist/cli-arguments.js.map +1 -1
  11. package/dist/cli.js +19 -142
  12. package/dist/cli.js.map +1 -1
  13. package/dist/clients/yandex-wiki.js +2 -6
  14. package/dist/clients/yandex-wiki.js.map +1 -1
  15. package/dist/consistency.d.ts +1 -1
  16. package/dist/consistency.js +6 -1
  17. package/dist/consistency.js.map +1 -1
  18. package/dist/content-model.d.ts +1 -0
  19. package/dist/content-model.js +118 -37
  20. package/dist/content-model.js.map +1 -1
  21. package/dist/conversion.d.ts +2 -0
  22. package/dist/conversion.js +42 -17
  23. package/dist/conversion.js.map +1 -1
  24. package/dist/domain.d.ts +22 -2
  25. package/dist/domain.js +10 -0
  26. package/dist/domain.js.map +1 -1
  27. package/dist/exclusive-lease.d.ts +14 -0
  28. package/dist/exclusive-lease.js +87 -0
  29. package/dist/exclusive-lease.js.map +1 -0
  30. package/dist/filesystem-export.d.ts +2 -0
  31. package/dist/filesystem-export.js +188 -167
  32. package/dist/filesystem-export.js.map +1 -1
  33. package/dist/graph.js +4 -4
  34. package/dist/graph.js.map +1 -1
  35. package/dist/http.js +1 -1
  36. package/dist/http.js.map +1 -1
  37. package/dist/interactive-config.js +55 -3
  38. package/dist/interactive-config.js.map +1 -1
  39. package/dist/interactive-init.d.ts +3 -3
  40. package/dist/interactive-init.js +7 -5
  41. package/dist/interactive-init.js.map +1 -1
  42. package/dist/interactive-migrate.d.ts +23 -0
  43. package/dist/interactive-migrate.js +578 -0
  44. package/dist/interactive-migrate.js.map +1 -0
  45. package/dist/issues.d.ts +1 -1
  46. package/dist/migration-command.d.ts +19 -0
  47. package/dist/migration-command.js +160 -0
  48. package/dist/migration-command.js.map +1 -0
  49. package/dist/migrator.js +439 -311
  50. package/dist/migrator.js.map +1 -1
  51. package/dist/report-catalog.d.ts +4 -0
  52. package/dist/report-catalog.js +4 -0
  53. package/dist/report-catalog.js.map +1 -1
  54. package/dist/report-contract.d.ts +6 -0
  55. package/dist/report-contract.js.map +1 -1
  56. package/dist/report-ui/index.html +19 -25
  57. package/dist/report.js +33 -22
  58. package/dist/report.js.map +1 -1
  59. package/dist/state.d.ts +16 -0
  60. package/dist/state.js +58 -32
  61. package/dist/state.js.map +1 -1
  62. package/dist/{init-prompts.d.ts → wizard-prompts.d.ts} +2 -2
  63. package/dist/{init-prompts.js → wizard-prompts.js} +3 -3
  64. package/dist/wizard-prompts.js.map +1 -0
  65. package/package.json +3 -7
  66. package/dist/init-prompts.js.map +0 -1
package/dist/domain.d.ts CHANGED
@@ -82,6 +82,12 @@ export declare class DestinationRevisionConflictError extends Error {
82
82
  readonly objectId: string;
83
83
  constructor(objectType: 'page' | 'comment', objectId: string, options?: ErrorOptions);
84
84
  }
85
+ export type OwnershipConflictReason = 'duplicate-source-mapping' | 'immutable-id-mismatch' | 'ambiguous-ownership-candidates';
86
+ export declare class DestinationOwnershipConflictError extends Error {
87
+ readonly reason: OwnershipConflictReason;
88
+ readonly candidateIds: string[];
89
+ constructor(reason: OwnershipConflictReason, candidateIds: string[], message: string, options?: ErrorOptions);
90
+ }
85
91
  export interface UploadedResource {
86
92
  id: string;
87
93
  name: string;
@@ -175,7 +181,7 @@ export interface SourceStatusExclusion {
175
181
  depth?: number;
176
182
  discoveredFromId?: string;
177
183
  metadataComplete?: boolean;
178
- reason: 'source-status';
184
+ reason: 'source-status' | 'inventory-drift';
179
185
  status?: string;
180
186
  }
181
187
  export interface DescendantDiscovery {
@@ -289,7 +295,7 @@ export interface HierarchyGap {
289
295
  pageId: string;
290
296
  missingParentId?: string;
291
297
  selectedParentId: string;
292
- reason: 'unavailable' | 'source-status';
298
+ reason: 'unavailable' | 'source-status' | 'inventory-drift';
293
299
  }
294
300
  export interface SourceSelection {
295
301
  requestedStatus: SourceStatus;
@@ -335,6 +341,14 @@ export interface NormalizedNode {
335
341
  sectionType?: 'single' | 'two_equal' | 'two_left_sidebar' | 'two_right_sidebar' | 'three_equal' | 'three_with_sidebars' | 'unsupported';
336
342
  };
337
343
  }
344
+ export type TransformationNoticeKind = 'normalization' | 'adaptation' | 'degradation';
345
+ export interface TransformationNotice {
346
+ code: string;
347
+ kind: TransformationNoticeKind;
348
+ message: string;
349
+ objectId?: string;
350
+ verification?: string;
351
+ }
338
352
  export interface NormalizedDocument {
339
353
  root: NormalizedNode;
340
354
  raw: string;
@@ -342,6 +356,8 @@ export interface NormalizedDocument {
342
356
  references: NormalizedReference[];
343
357
  warnings: string[];
344
358
  normalizations: string[];
359
+ adaptations: string[];
360
+ notices: TransformationNotice[];
345
361
  degraded: boolean;
346
362
  semanticallyEmpty: boolean;
347
363
  metrics: {
@@ -349,6 +365,7 @@ export interface NormalizedDocument {
349
365
  images: number;
350
366
  tables: number;
351
367
  codeBlocks: number;
368
+ preservedExternalReferences: number;
352
369
  };
353
370
  }
354
371
  export interface RenderContext {
@@ -365,7 +382,10 @@ export interface RenderedDocument {
365
382
  body: string;
366
383
  warnings: string[];
367
384
  normalizations: string[];
385
+ adaptations: string[];
386
+ notices: TransformationNotice[];
368
387
  degraded: boolean;
369
388
  representedReferences: number;
389
+ preservedExternalReferences: number;
370
390
  }
371
391
  export declare function assertTransferCapabilities(source: SourceAdapter, destination: DestinationAdapter | undefined, scope: TransferScope, sourceStatus?: SourceStatus, comments?: boolean): void;
package/dist/domain.js CHANGED
@@ -18,6 +18,16 @@ export class DestinationRevisionConflictError extends Error {
18
18
  this.name = 'DestinationRevisionConflictError';
19
19
  }
20
20
  }
21
+ export class DestinationOwnershipConflictError extends Error {
22
+ reason;
23
+ candidateIds;
24
+ constructor(reason, candidateIds, message, options) {
25
+ super(message, options);
26
+ this.reason = reason;
27
+ this.candidateIds = candidateIds;
28
+ this.name = 'DestinationOwnershipConflictError';
29
+ }
30
+ }
21
31
  export function assertTransferCapabilities(source, destination, scope, sourceStatus = 'all', comments = true) {
22
32
  const missing = [];
23
33
  if (!source.capabilities.readPages)
@@ -1 +1 @@
1
- {"version":3,"file":"domain.js","sourceRoot":"","sources":["../src/domain.ts"],"names":[],"mappings":"AAiFA,MAAM,OAAO,6BAA8B,SAAQ,KAAK;IAE3C;IACA;IAFX,YACW,KAAa,EACb,MAAc,EACvB,OAAsB;QAEtB,KAAK,CAAC,+CAA+C,KAAK,GAAG,EAAE,OAAO,CAAC,CAAC;QAJ/D,UAAK,GAAL,KAAK,CAAQ;QACb,WAAM,GAAN,MAAM,CAAQ;QAIvB,IAAI,CAAC,IAAI,GAAG,+BAA+B,CAAC;IAC9C,CAAC;CACF;AAED,MAAM,OAAO,gCAAiC,SAAQ,KAAK;IAE9C;IACA;IAFX,YACW,UAA8B,EAC9B,QAAgB,EACzB,OAAsB;QAEtB,KAAK,CAAC,eAAe,UAAU,IAAI,QAAQ,uBAAuB,EAAE,OAAO,CAAC,CAAC;QAJpE,eAAU,GAAV,UAAU,CAAoB;QAC9B,aAAQ,GAAR,QAAQ,CAAQ;QAIzB,IAAI,CAAC,IAAI,GAAG,kCAAkC,CAAC;IACjD,CAAC;CACF;AAgQD,MAAM,UAAU,0BAA0B,CACxC,MAAqB,EACrB,WAA2C,EAC3C,KAAoB,EACpB,eAA6B,KAAK,EAClC,QAAQ,GAAG,IAAI;IAEf,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,SAAS;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,cAAc,CAAC,CAAC;IACvF,IAAI,KAAK,KAAK,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,aAAa;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,CAAC;IACnH,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,aAAa;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,CAAC;IAC/F,IAAI,WAAW,IAAI,CAAC,WAAW,CAAC,YAAY,CAAC,UAAU;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,eAAe,CAAC,CAAC;IAClH,IAAI,WAAW,IAAI,CAAC,WAAW,CAAC,YAAY,CAAC,cAAc;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,mBAAmB,CAAC,CAAC;IAC1H,IAAI,WAAW,IAAI,CAAC,WAAW,CAAC,YAAY,CAAC,YAAY;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,iBAAiB,CAAC,CAAC;IACtH,IAAI,QAAQ,IAAI,MAAM,CAAC,YAAY,CAAC,YAAY,KAAK,IAAI;QAAE,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,wCAAwC,CAAC,CAAC;IACxI,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClH,MAAM,oBAAoB,GAAG,MAAM,CAAC,oBAAoB,EAAE,CAAC,KAAK,EAAE,YAAY,CAAC,IAAI,YAAY,KAAK,KAAK,CAAC;IAC1G,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI,sCAAsC,YAAY,gBAAgB,KAAK,wDAAwD,CAAC,CAAC;IACzL,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"domain.js","sourceRoot":"","sources":["../src/domain.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ interface LeaseMetadata {
2
+ pid: number;
3
+ hostname: string;
4
+ startedAt: string;
5
+ purpose: string;
6
+ target: string;
7
+ }
8
+ export interface ExclusiveLease {
9
+ filename: string;
10
+ metadata: LeaseMetadata;
11
+ release(): Promise<void>;
12
+ }
13
+ export declare function acquireExclusiveLease(filename: string, purpose: string, target: string): Promise<ExclusiveLease>;
14
+ export {};
@@ -0,0 +1,87 @@
1
+ import { hostname } from 'node:os';
2
+ import { mkdir, open, readFile, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ function localProcessIsAlive(pid) {
5
+ if (!Number.isSafeInteger(pid) || pid <= 0)
6
+ return false;
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ return error.code === 'EPERM';
13
+ }
14
+ }
15
+ async function existingLease(filename) {
16
+ try {
17
+ const value = JSON.parse(await readFile(filename, 'utf8'));
18
+ if (typeof value.pid !== 'number'
19
+ || typeof value.hostname !== 'string'
20
+ || typeof value.startedAt !== 'string'
21
+ || typeof value.purpose !== 'string'
22
+ || typeof value.target !== 'string')
23
+ return undefined;
24
+ return value;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ function isProvablyStale(metadata) {
31
+ return metadata?.hostname === hostname() && !localProcessIsAlive(metadata.pid);
32
+ }
33
+ export async function acquireExclusiveLease(filename, purpose, target) {
34
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
35
+ const metadata = { pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString(), purpose, target };
36
+ for (let attempt = 0; attempt < 2; attempt += 1) {
37
+ let created = false;
38
+ try {
39
+ const handle = await open(filename, 'wx', 0o600);
40
+ created = true;
41
+ try {
42
+ await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`);
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ let released = false;
48
+ return {
49
+ filename,
50
+ metadata,
51
+ async release() {
52
+ if (released)
53
+ return;
54
+ released = true;
55
+ const current = await existingLease(filename);
56
+ if (current?.pid === metadata.pid && current.hostname === metadata.hostname && current.startedAt === metadata.startedAt) {
57
+ await unlink(filename).catch((error) => {
58
+ if (error.code !== 'ENOENT')
59
+ throw error;
60
+ });
61
+ }
62
+ },
63
+ };
64
+ }
65
+ catch (error) {
66
+ if (error.code !== 'EEXIST') {
67
+ if (created)
68
+ await unlink(filename).catch(() => undefined);
69
+ throw error;
70
+ }
71
+ const current = await existingLease(filename);
72
+ if (attempt === 0 && isProvablyStale(current)) {
73
+ await unlink(filename).catch((unlinkError) => {
74
+ if (unlinkError.code !== 'ENOENT')
75
+ throw unlinkError;
76
+ });
77
+ continue;
78
+ }
79
+ const detail = current
80
+ ? `PID ${current.pid} on ${current.hostname}, started ${current.startedAt}`
81
+ : 'the lock metadata is unreadable and cannot be proven active';
82
+ throw new Error(`Another ${purpose} is active for "${target}" (${detail}). Wait for it to finish or remove the lock only after proving that process is no longer running.`);
83
+ }
84
+ }
85
+ throw new Error(`Could not acquire ${purpose} lock for "${target}"`);
86
+ }
87
+ //# sourceMappingURL=exclusive-lease.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exclusive-lease.js","sourceRoot":"","sources":["../src/exclusive-lease.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,8 @@ export interface ExportManifestPage {
15
15
  }>;
16
16
  degraded: boolean;
17
17
  warnings: string[];
18
+ adaptations: string[];
19
+ preservedExternalReferences: number;
18
20
  comments: {
19
21
  status: CommentTransferStatus;
20
22
  threads: number;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { createWriteStream } from 'node:fs';
3
- import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { access, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { ZipArchive } from '@archiver/archiver';
@@ -9,6 +9,7 @@ import { z } from 'zod';
9
9
  import { appendGridSnapshots, normalizePageContent, renderMarkdown, renderStaticHtml, setSourceResourceBinding, sourceResourceBindings } from './content-model.js';
10
10
  import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive } from './comments.js';
11
11
  import { DestinationNameAllocator } from './resource-reconciliation.js';
12
+ import { acquireExclusiveLease } from './exclusive-lease.js';
12
13
  const manifestPageSchema = z.object({
13
14
  sourceCanonicalId: z.string(),
14
15
  sourceUrl: z.string(),
@@ -23,6 +24,8 @@ const manifestPageSchema = z.object({
23
24
  }).strict()),
24
25
  degraded: z.boolean(),
25
26
  warnings: z.array(z.string()),
27
+ adaptations: z.array(z.string()),
28
+ preservedExternalReferences: z.number().int().nonnegative(),
26
29
  comments: z.object({
27
30
  status: z.enum(['planned', 'complete', 'partial', 'failed', 'conflict', 'stale', 'skipped']),
28
31
  threads: z.number().int().nonnegative(),
@@ -155,6 +158,8 @@ export class FilesystemExporter {
155
158
  resources: [],
156
159
  degraded: false,
157
160
  warnings: [],
161
+ adaptations: [],
162
+ preservedExternalReferences: 0,
158
163
  comments: {
159
164
  status: input.commentsEnabled === false ? 'skipped' : 'planned',
160
165
  threads: input.commentsByPage?.get(page.canonicalId)?.threads.length ?? 0,
@@ -167,186 +172,202 @@ export class FilesystemExporter {
167
172
  }));
168
173
  return { directory: input.directory, manifest, warnings, errors };
169
174
  }
170
- const parent = path.dirname(input.directory);
171
- const staging = path.join(parent, `.${path.basename(input.directory)}.ywtc-staging-${randomUUID().slice(0, 8)}`);
172
- await mkdir(parent, { recursive: true, mode: 0o700 });
173
- await mkdir(staging, { recursive: false, mode: 0o700 });
174
- let backup;
175
+ const lease = await acquireExclusiveLease(`${input.directory}.ywtc.lock`, 'ywtc filesystem export', input.directory);
175
176
  try {
176
- const sourcePages = new Map(input.graph.pages.map((page) => [page.canonicalId, page]));
177
- const queue = new PQueue({ concurrency: this.concurrency });
178
- const resourceMapByPage = new Map();
179
- const resourcesByPage = new Map();
180
- const commentBodies = new Map();
181
- await Promise.all(input.graph.pages.map((page) => queue.add(async () => {
182
- if (input.signal?.aborted)
183
- throw input.signal.reason ?? new Error('Export cancelled');
184
- const resources = input.resourcesByPage?.get(page.canonicalId) ?? await this.source.listResources(page);
185
- resourcesByPage.set(page.canonicalId, resources);
186
- const pageResources = sourceResourceBindings(page, resources);
187
- const nameAllocator = new DestinationNameAllocator();
188
- const manifestResources = [];
189
- const downloadedByResource = new Map();
190
- for (const resource of resources.filter((item) => item.kind !== 'grid')) {
191
- const name = nameAllocator.allocate(resource);
192
- const relative = `attachments/${stableSegment(page)}/${name}`;
193
- const destination = path.join(staging, relative);
194
- await mkdir(path.dirname(destination), { recursive: true });
195
- const downloaded = await this.source.downloadResource(resource, destination);
196
- downloadedByResource.set(resource.id, downloaded);
197
- setSourceResourceBinding(pageResources, page, resources, resource, { kind: 'file', path: relative, name });
198
- manifestResources.push({ name, path: relative, size: downloaded.size, sha256: downloaded.sha256 });
199
- input.onItem?.(`attachment ${name}`);
200
- }
201
- resourceMapByPage.set(page.canonicalId, pageResources);
202
- const inventory = input.commentsByPage?.get(page.canonicalId);
203
- let comments = {
204
- status: input.commentsEnabled === false ? 'skipped' : inventory?.status === 'failed' ? 'failed' : 'complete',
205
- threads: inventory?.threads.length ?? 0,
206
- comments: inventory?.comments ?? 0,
207
- participants: inventory?.participants ?? 0,
208
- attachments: inventory?.attachments ?? 0,
209
- bytes: inventory?.knownBytes ?? 0,
210
- ...(inventory?.fingerprint ? { fingerprint: inventory.fingerprint } : {}),
211
- ...(inventory?.error ? { error: inventory.error } : {}),
212
- };
213
- if (input.commentsEnabled !== false && inventory?.status === 'complete' && inventory.fingerprint) {
214
- try {
215
- const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
216
- const files = commentAttachmentUses(inventory).flatMap((use) => {
217
- const id = use.reference.resourceId;
218
- const resource = id ? resourceById.get(id) : undefined;
219
- const downloaded = id ? downloadedByResource.get(id) : undefined;
220
- return resource && downloaded ? [{
221
- commentId: use.commentId,
222
- reference: use.reference.href,
223
- originalName: resource.preferredName ?? resource.name,
224
- ...downloaded,
225
- }] : [];
226
- });
227
- const zip = await createCommentArchiveZip({
228
- pageKey: page.id,
229
- fingerprint: inventory.fingerprint,
230
- files,
231
- destinationDirectory: path.join(staging, 'attachments', stableSegment(page)),
232
- });
233
- const archivePath = zip ? path.relative(staging, zip.path).split(path.sep).join('/') : undefined;
234
- const names = new Map();
235
- if (zip) {
236
- const byHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
237
- for (const [resourceId, downloaded] of downloadedByResource) {
238
- const name = byHash.get(downloaded.sha256);
239
- if (name)
240
- names.set(resourceId, name);
177
+ const parent = path.dirname(input.directory);
178
+ const staging = path.join(parent, `.${path.basename(input.directory)}.ywtc-staging-${randomUUID().slice(0, 8)}`);
179
+ await mkdir(parent, { recursive: true, mode: 0o700 });
180
+ await mkdir(staging, { recursive: false, mode: 0o700 });
181
+ let backup;
182
+ try {
183
+ const sourcePages = new Map(input.graph.pages.map((page) => [page.canonicalId, page]));
184
+ const queue = new PQueue({ concurrency: this.concurrency });
185
+ const resourceMapByPage = new Map();
186
+ const resourcesByPage = new Map();
187
+ const commentBodies = new Map();
188
+ await Promise.all(input.graph.pages.map((page) => queue.add(async () => {
189
+ if (input.signal?.aborted)
190
+ throw input.signal.reason ?? new Error('Export cancelled');
191
+ const resources = input.resourcesByPage?.get(page.canonicalId) ?? await this.source.listResources(page);
192
+ resourcesByPage.set(page.canonicalId, resources);
193
+ const pageResources = sourceResourceBindings(page, resources);
194
+ const nameAllocator = new DestinationNameAllocator();
195
+ const manifestResources = [];
196
+ const downloadedByResource = new Map();
197
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
198
+ const name = nameAllocator.allocate(resource);
199
+ const relative = `attachments/${stableSegment(page)}/${name}`;
200
+ const destination = path.join(staging, relative);
201
+ await mkdir(path.dirname(destination), { recursive: true });
202
+ const downloaded = await this.source.downloadResource(resource, destination);
203
+ downloadedByResource.set(resource.id, downloaded);
204
+ setSourceResourceBinding(pageResources, page, resources, resource, { kind: 'file', path: relative, name });
205
+ manifestResources.push({ name, path: relative, size: downloaded.size, sha256: downloaded.sha256 });
206
+ input.onItem?.(`attachment ${name}`);
207
+ }
208
+ resourceMapByPage.set(page.canonicalId, pageResources);
209
+ const inventory = input.commentsByPage?.get(page.canonicalId);
210
+ let comments = {
211
+ status: input.commentsEnabled === false ? 'skipped' : inventory?.status === 'failed' ? 'failed' : 'complete',
212
+ threads: inventory?.threads.length ?? 0,
213
+ comments: inventory?.comments ?? 0,
214
+ participants: inventory?.participants ?? 0,
215
+ attachments: inventory?.attachments ?? 0,
216
+ bytes: inventory?.knownBytes ?? 0,
217
+ ...(inventory?.fingerprint ? { fingerprint: inventory.fingerprint } : {}),
218
+ ...(inventory?.error ? { error: inventory.error } : {}),
219
+ };
220
+ if (input.commentsEnabled !== false && inventory?.status === 'complete' && inventory.fingerprint) {
221
+ try {
222
+ const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
223
+ const files = commentAttachmentUses(inventory).flatMap((use) => {
224
+ const id = use.reference.resourceId;
225
+ const resource = id ? resourceById.get(id) : undefined;
226
+ const downloaded = id ? downloadedByResource.get(id) : undefined;
227
+ return resource && downloaded ? [{
228
+ commentId: use.commentId,
229
+ reference: use.reference.href,
230
+ originalName: resource.preferredName ?? resource.name,
231
+ ...downloaded,
232
+ }] : [];
233
+ });
234
+ const zip = await createCommentArchiveZip({
235
+ pageKey: page.id,
236
+ fingerprint: inventory.fingerprint,
237
+ files,
238
+ destinationDirectory: path.join(staging, 'attachments', stableSegment(page)),
239
+ });
240
+ const archivePath = zip ? path.relative(staging, zip.path).split(path.sep).join('/') : undefined;
241
+ const names = new Map();
242
+ if (zip) {
243
+ const byHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
244
+ for (const [resourceId, downloaded] of downloadedByResource) {
245
+ const name = byHash.get(downloaded.sha256);
246
+ if (name)
247
+ names.set(resourceId, name);
248
+ }
241
249
  }
250
+ const pageFile = pagePaths.get(page.canonicalId);
251
+ commentBodies.set(page.canonicalId, renderCommentArchive(inventory, input.format, {
252
+ sourceUrl: page.url,
253
+ ...(zip && archivePath ? { archiveName: zip.name, archiveHref: relativeHref(pageFile, archivePath) } : {}),
254
+ attachmentNames: names,
255
+ }));
256
+ comments = {
257
+ ...comments,
258
+ status: inventory.warnings.length ? 'partial' : 'complete',
259
+ ...(zip && archivePath ? { archiveName: zip.name, archivePath, bytes: zip.size } : {}),
260
+ ...(inventory.warnings.length ? { error: inventory.warnings.join('; ') } : {}),
261
+ };
262
+ }
263
+ catch (error) {
264
+ const message = error instanceof Error ? error.message : String(error);
265
+ comments = { ...comments, status: 'failed', error: message };
242
266
  }
267
+ }
268
+ manifest.pages.push({
269
+ sourceCanonicalId: page.canonicalId,
270
+ sourceUrl: page.url,
271
+ title: page.title,
272
+ path: pagePaths.get(page.canonicalId),
273
+ ...(page.projectedParentId ? { parentId: page.projectedParentId } : {}),
274
+ resources: manifestResources,
275
+ degraded: false,
276
+ warnings: [],
277
+ adaptations: [],
278
+ preservedExternalReferences: 0,
279
+ comments,
280
+ });
281
+ })));
282
+ const children = new Map();
283
+ for (const page of input.graph.pages) {
284
+ const parentId = page.projectedParentId ?? '__root__';
285
+ const siblings = children.get(parentId) ?? [];
286
+ siblings.push(page);
287
+ children.set(parentId, siblings);
288
+ }
289
+ const navigationFor = (currentPath) => {
290
+ const renderItems = (parentId) => `<ul>${(children.get(parentId) ?? []).map((page) => {
243
291
  const pageFile = pagePaths.get(page.canonicalId);
244
- commentBodies.set(page.canonicalId, renderCommentArchive(inventory, input.format, {
245
- sourceUrl: page.url,
246
- ...(zip && archivePath ? { archiveName: zip.name, archiveHref: relativeHref(pageFile, archivePath) } : {}),
247
- attachmentNames: names,
248
- }));
249
- comments = {
250
- ...comments,
251
- status: inventory.warnings.length ? 'partial' : 'complete',
252
- ...(zip && archivePath ? { archiveName: zip.name, archivePath, bytes: zip.size } : {}),
253
- ...(inventory.warnings.length ? { error: inventory.warnings.join('; ') } : {}),
254
- };
292
+ const active = pageFile === currentPath ? ' aria-current="page"' : '';
293
+ const title = page.title.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
294
+ return `<li><a${active} href="${relativeHref(currentPath, pageFile)}">${title}</a>${renderItems(page.canonicalId)}</li>`;
295
+ }).join('')}</ul>`;
296
+ return renderItems('__root__');
297
+ };
298
+ for (const page of input.graph.pages) {
299
+ const fullPage = input.loadPage ? await input.loadPage(page) : page;
300
+ const relative = pagePaths.get(page.canonicalId);
301
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
302
+ const document = input.loadDocument
303
+ ? await input.loadDocument(page)
304
+ : appendGridSnapshots(normalizePageContent(fullPage), resources);
305
+ const pages = new Map([...pagePaths].map(([id, file]) => [id, { path: file, title: sourcePages.get(id)?.title ?? id }]));
306
+ const context = {
307
+ pages,
308
+ resources: resourceMapByPage.get(page.canonicalId) ?? new Map(),
309
+ sourcePages,
310
+ currentPageId: page.canonicalId,
311
+ currentPath: relative,
312
+ };
313
+ const rendered = input.format === 'html' ? await renderStaticHtml(document, context) : await renderMarkdown(document, context);
314
+ const output = input.format === 'html'
315
+ ? htmlPage(page.title, navigationFor(relative), `${rendered.body}${commentBodies.get(page.canonicalId) ?? ''}`, relativeHref(relative, 'assets/site.css'))
316
+ : `# ${page.title}\n\n${rendered.body}${commentBodies.has(page.canonicalId) ? `\n\n## Комментарии\n\n${commentBodies.get(page.canonicalId)}` : ''}`;
317
+ await mkdir(path.dirname(path.join(staging, relative)), { recursive: true });
318
+ await writeFile(path.join(staging, relative), output, { mode: 0o600 });
319
+ const manifestPage = manifest.pages.find((item) => item.sourceCanonicalId === page.canonicalId);
320
+ manifestPage.degraded = rendered.notices.some((notice) => notice.kind === 'degradation');
321
+ manifestPage.warnings.push(...rendered.warnings);
322
+ manifestPage.adaptations.push(...rendered.adaptations);
323
+ manifestPage.preservedExternalReferences = rendered.preservedExternalReferences;
324
+ warnings.push(...rendered.warnings.map((warning) => `${page.title}: ${warning}`));
325
+ if (manifestPage.degraded) {
326
+ const snapshot = `_source/${stableSegment(page)}.${page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml'}`;
327
+ await mkdir(path.dirname(path.join(staging, snapshot)), { recursive: true });
328
+ await writeFile(path.join(staging, snapshot), fullPage.rawContent, { mode: 0o600 });
329
+ }
330
+ input.onItem?.(`page ${page.title}`);
331
+ }
332
+ manifest.pages.sort((left, right) => left.path.localeCompare(right.path));
333
+ if (input.format === 'html') {
334
+ await mkdir(path.join(staging, 'assets'), { recursive: true });
335
+ await writeFile(path.join(staging, 'assets/site.css'), SITE_CSS, { mode: 0o600 });
336
+ }
337
+ await writeFile(path.join(staging, '.ywtc-export.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
338
+ await verifyGeneratedTree(staging, manifest);
339
+ if (await exists(input.directory)) {
340
+ const selected = await stat(input.directory);
341
+ const empty = selected.isDirectory() && (await readdir(input.directory)).length === 0;
342
+ if (!selected.isDirectory() || (!empty && !(await managedManifest(input.directory, input.identity)))) {
343
+ throw new Error(`Refusing to replace unmanaged destination "${input.directory}". Choose an empty path or a directory with a matching .ywtc-export.json.`);
344
+ }
345
+ if (empty) {
346
+ await rmdir(input.directory);
255
347
  }
256
- catch (error) {
257
- const message = error instanceof Error ? error.message : String(error);
258
- comments = { ...comments, status: 'failed', error: message };
348
+ else {
349
+ backup = `${input.directory}.backup-${timestamp()}`;
350
+ await rename(input.directory, backup);
259
351
  }
260
352
  }
261
- manifest.pages.push({
262
- sourceCanonicalId: page.canonicalId,
263
- sourceUrl: page.url,
264
- title: page.title,
265
- path: pagePaths.get(page.canonicalId),
266
- ...(page.projectedParentId ? { parentId: page.projectedParentId } : {}),
267
- resources: manifestResources,
268
- degraded: false,
269
- warnings: [],
270
- comments,
271
- });
272
- })));
273
- const children = new Map();
274
- for (const page of input.graph.pages) {
275
- const parentId = page.projectedParentId ?? '__root__';
276
- const siblings = children.get(parentId) ?? [];
277
- siblings.push(page);
278
- children.set(parentId, siblings);
279
- }
280
- const navigationFor = (currentPath) => {
281
- const renderItems = (parentId) => `<ul>${(children.get(parentId) ?? []).map((page) => {
282
- const pageFile = pagePaths.get(page.canonicalId);
283
- const active = pageFile === currentPath ? ' aria-current="page"' : '';
284
- const title = page.title.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
285
- return `<li><a${active} href="${relativeHref(currentPath, pageFile)}">${title}</a>${renderItems(page.canonicalId)}</li>`;
286
- }).join('')}</ul>`;
287
- return renderItems('__root__');
288
- };
289
- for (const page of input.graph.pages) {
290
- const fullPage = input.loadPage ? await input.loadPage(page) : page;
291
- const relative = pagePaths.get(page.canonicalId);
292
- const resources = resourcesByPage.get(page.canonicalId) ?? [];
293
- const document = input.loadDocument
294
- ? await input.loadDocument(page)
295
- : appendGridSnapshots(normalizePageContent(fullPage), resources);
296
- const pages = new Map([...pagePaths].map(([id, file]) => [id, { path: file, title: sourcePages.get(id)?.title ?? id }]));
297
- const context = {
298
- pages,
299
- resources: resourceMapByPage.get(page.canonicalId) ?? new Map(),
300
- sourcePages,
301
- currentPageId: page.canonicalId,
302
- currentPath: relative,
303
- };
304
- const rendered = input.format === 'html' ? await renderStaticHtml(document, context) : await renderMarkdown(document, context);
305
- const output = input.format === 'html'
306
- ? htmlPage(page.title, navigationFor(relative), `${rendered.body}${commentBodies.get(page.canonicalId) ?? ''}`, relativeHref(relative, 'assets/site.css'))
307
- : `# ${page.title}\n\n${rendered.body}${commentBodies.has(page.canonicalId) ? `\n\n## Комментарии\n\n${commentBodies.get(page.canonicalId)}` : ''}`;
308
- await mkdir(path.dirname(path.join(staging, relative)), { recursive: true });
309
- await writeFile(path.join(staging, relative), output, { mode: 0o600 });
310
- const manifestPage = manifest.pages.find((item) => item.sourceCanonicalId === page.canonicalId);
311
- manifestPage.degraded = rendered.degraded;
312
- manifestPage.warnings.push(...rendered.warnings);
313
- warnings.push(...rendered.warnings.map((warning) => `${page.title}: ${warning}`));
314
- if (rendered.degraded) {
315
- const snapshot = `_source/${stableSegment(page)}.${page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml'}`;
316
- await mkdir(path.dirname(path.join(staging, snapshot)), { recursive: true });
317
- await writeFile(path.join(staging, snapshot), fullPage.rawContent, { mode: 0o600 });
353
+ try {
354
+ await rename(staging, input.directory);
318
355
  }
319
- input.onItem?.(`page ${page.title}`);
320
- }
321
- manifest.pages.sort((left, right) => left.path.localeCompare(right.path));
322
- if (input.format === 'html') {
323
- await mkdir(path.join(staging, 'assets'), { recursive: true });
324
- await writeFile(path.join(staging, 'assets/site.css'), SITE_CSS, { mode: 0o600 });
325
- }
326
- await writeFile(path.join(staging, '.ywtc-export.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
327
- await verifyGeneratedTree(staging, manifest);
328
- if (await exists(input.directory)) {
329
- const selected = await stat(input.directory);
330
- if (!selected.isDirectory() || !(await managedManifest(input.directory, input.identity))) {
331
- throw new Error(`Refusing to replace unmanaged destination "${input.directory}". Choose an empty path or a directory with a matching .ywtc-export.json.`);
356
+ catch (error) {
357
+ if (backup && !(await exists(input.directory)))
358
+ await rename(backup, input.directory);
359
+ throw error;
332
360
  }
333
- backup = `${input.directory}.backup-${timestamp()}`;
334
- await rename(input.directory, backup);
335
- }
336
- try {
337
- await rename(staging, input.directory);
361
+ const archive = input.archive ? await createArchive(input.directory) : undefined;
362
+ return { directory: input.directory, ...(archive ? { archive } : {}), ...(backup ? { backup } : {}), manifest, warnings, errors };
338
363
  }
339
364
  catch (error) {
340
- if (backup && !(await exists(input.directory)))
341
- await rename(backup, input.directory);
365
+ await rm(staging, { recursive: true, force: true });
342
366
  throw error;
343
367
  }
344
- const archive = input.archive ? await createArchive(input.directory) : undefined;
345
- return { directory: input.directory, ...(archive ? { archive } : {}), ...(backup ? { backup } : {}), manifest, warnings, errors };
346
368
  }
347
- catch (error) {
348
- await rm(staging, { recursive: true, force: true });
349
- throw error;
369
+ finally {
370
+ await lease.release();
350
371
  }
351
372
  }
352
373
  }