@stll/folio-cli 0.3.0 → 0.3.2
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/dist/document.d.ts +6 -1
- package/dist/document.js +9 -1
- package/dist/editor-lease.d.ts +8 -4
- package/dist/editor-lease.js +14 -5
- package/dist/errors.d.ts +13 -3
- package/dist/lock.d.ts +6 -1
- package/dist/lock.js +12 -3
- package/dist/main-document-part.d.ts +26 -0
- package/dist/main-document-part.js +323 -0
- package/dist/output.d.ts +2 -2
- package/dist/package-parts.d.ts +2 -1
- package/dist/package-parts.js +5 -1
- package/dist/package.js +1 -1
- package/dist/preview-commands.js +1 -4
- package/dist/serve.d.ts +2 -1
- package/dist/serve.js +6 -2
- package/package.json +3 -3
package/dist/document.d.ts
CHANGED
|
@@ -16,7 +16,12 @@ type LoadedFile = {
|
|
|
16
16
|
/** Hard links to the file; a write refuses a file with more than one. */
|
|
17
17
|
links: number;
|
|
18
18
|
};
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Read one input file, refusing directories, files over the size bound, and
|
|
21
|
+
* anything that is not a WordprocessingML package. Every command and tool
|
|
22
|
+
* that takes a document reads it here, so none can treat an archive without
|
|
23
|
+
* a main document part as an empty document.
|
|
24
|
+
*/
|
|
20
25
|
declare const readDocumentFile: (inputPath: string) => Promise<Result<LoadedFile, FolioCliError>>;
|
|
21
26
|
/**
|
|
22
27
|
* Parse a loaded file into a headless reviewer. `author` is required for a
|
package/dist/document.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
2
|
import { NO_FOLLOW, errnoCode, fileVersionOf, sameFile } from "./file-system.js";
|
|
3
|
+
import { checkWordprocessingPackage } from "./main-document-part.js";
|
|
3
4
|
import { Result } from "better-result";
|
|
4
5
|
import { constants } from "node:fs";
|
|
5
6
|
import { open, realpath, stat } from "node:fs/promises";
|
|
@@ -67,7 +68,12 @@ const readThroughHandle = async (real) => {
|
|
|
67
68
|
}));
|
|
68
69
|
return Result.ok(read.value);
|
|
69
70
|
};
|
|
70
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Read one input file, refusing directories, files over the size bound, and
|
|
73
|
+
* anything that is not a WordprocessingML package. Every command and tool
|
|
74
|
+
* that takes a document reads it here, so none can treat an archive without
|
|
75
|
+
* a main document part as an empty document.
|
|
76
|
+
*/
|
|
71
77
|
const readDocumentFile = async (inputPath) => {
|
|
72
78
|
const absolute = path.resolve(inputPath);
|
|
73
79
|
const real = await Result.tryPromise({
|
|
@@ -78,6 +84,8 @@ const readDocumentFile = async (inputPath) => {
|
|
|
78
84
|
const opened = await readThroughHandle(real.value);
|
|
79
85
|
if (opened.isErr()) return Result.err(opened.error);
|
|
80
86
|
const { bytes, identity, links } = opened.value;
|
|
87
|
+
const checked = await checkWordprocessingPackage(real.value, bytes);
|
|
88
|
+
if (checked.isErr()) return Result.err(checked.error);
|
|
81
89
|
return Result.ok({
|
|
82
90
|
path: real.value,
|
|
83
91
|
bytes,
|
package/dist/editor-lease.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { AcquiredLease, PublicLockHolder } from "./lock.js";
|
|
1
2
|
import { FolioCliError } from "./errors.js";
|
|
2
|
-
import { AcquiredLease, LockHolder } from "./lock.js";
|
|
3
3
|
import { Result } from "better-result";
|
|
4
4
|
//#region src/editor-lease.d.ts
|
|
5
5
|
/** How long an editor's lease lasts without renewal. */
|
|
@@ -24,6 +24,10 @@ type FlushRequest = {
|
|
|
24
24
|
/** After this the writer has stopped waiting; the request is void. */
|
|
25
25
|
deadline: string;
|
|
26
26
|
};
|
|
27
|
+
/** Request metadata safe to include in a refused editor lease response. */
|
|
28
|
+
type PublicFlushRequest = Omit<FlushRequest, "leaseToken"> & {
|
|
29
|
+
leaseToken?: never;
|
|
30
|
+
};
|
|
27
31
|
declare const flushRequestPathFor: (documentPath: string, id: string) => string;
|
|
28
32
|
/**
|
|
29
33
|
* The flush requests beside `documentPath` whose writer is still waiting.
|
|
@@ -39,13 +43,13 @@ type FlushOutcome =
|
|
|
39
43
|
/** The holder released within the wait; `versionBefore` is the file's version when asked. */
|
|
40
44
|
{
|
|
41
45
|
type: "flushed";
|
|
42
|
-
holder:
|
|
46
|
+
holder: PublicLockHolder;
|
|
43
47
|
versionBefore: string | null;
|
|
44
48
|
} |
|
|
45
49
|
/** The holder never released; the lease was taken by the ordinary rules (stale, or `force`). */
|
|
46
50
|
{
|
|
47
51
|
type: "timedOut";
|
|
48
|
-
holder:
|
|
52
|
+
holder: PublicLockHolder;
|
|
49
53
|
};
|
|
50
54
|
type LeaseForWrite = {
|
|
51
55
|
lease: AcquiredLease;
|
|
@@ -111,4 +115,4 @@ type WatchFlushRequestsOptions = {
|
|
|
111
115
|
*/
|
|
112
116
|
declare const watchFlushRequests: ({ documentPath, token, onRequest, pollMs }: WatchFlushRequestsOptions) => FlushWatcher;
|
|
113
117
|
//#endregion
|
|
114
|
-
export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, FlushOutcome, FlushRequest, FlushWatcher, LeaseForWrite, LeaseKeeper, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
|
|
118
|
+
export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, FlushOutcome, FlushRequest, FlushWatcher, LeaseForWrite, LeaseKeeper, PublicFlushRequest, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
|
package/dist/editor-lease.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
2
|
import { fileVersionOf } from "./file-system.js";
|
|
3
|
-
import { DEFAULT_LEASE_OWNER, acquireLease, readLease } from "./lock.js";
|
|
3
|
+
import { DEFAULT_LEASE_OWNER, acquireLease, publicLockHolder, readLease } from "./lock.js";
|
|
4
4
|
import { readSidecarFile, writeNewSidecarFile } from "./sidecar.js";
|
|
5
5
|
import { Result } from "better-result";
|
|
6
6
|
import { watch } from "node:fs";
|
|
@@ -62,6 +62,15 @@ const EDITOR_RENEW_MS = 10 * 1e3;
|
|
|
62
62
|
const DEFAULT_FLUSH_WAIT_MS = 5 * 1e3;
|
|
63
63
|
/** How often a writer retries the lease, and an editor re-checks for requests. */
|
|
64
64
|
const FLUSH_POLL_MS = 100;
|
|
65
|
+
const publicFlushRequest = ({ id, owner, pid, host, txId, requestedAt, deadline }) => ({
|
|
66
|
+
id,
|
|
67
|
+
owner,
|
|
68
|
+
pid,
|
|
69
|
+
host,
|
|
70
|
+
txId,
|
|
71
|
+
requestedAt,
|
|
72
|
+
deadline
|
|
73
|
+
});
|
|
65
74
|
const flushPrefixFor = (documentPath) => `.${path.basename(documentPath)}.folio-flush-`;
|
|
66
75
|
const flushRequestPathFor = (documentPath, id) => path.join(path.dirname(documentPath), `${flushPrefixFor(documentPath)}${id}`);
|
|
67
76
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -172,7 +181,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
|
|
|
172
181
|
lease: attempt.value,
|
|
173
182
|
flush: {
|
|
174
183
|
type: "flushed",
|
|
175
|
-
holder,
|
|
184
|
+
holder: publicLockHolder(holder),
|
|
176
185
|
versionBefore
|
|
177
186
|
}
|
|
178
187
|
});
|
|
@@ -191,7 +200,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
|
|
|
191
200
|
lease: last.value,
|
|
192
201
|
flush: {
|
|
193
202
|
type: "timedOut",
|
|
194
|
-
holder
|
|
203
|
+
holder: publicLockHolder(holder)
|
|
195
204
|
}
|
|
196
205
|
});
|
|
197
206
|
if (last.error.code !== FOLIO_CLI_ERROR_CODES.locked) return Result.err(last.error);
|
|
@@ -200,7 +209,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
|
|
|
200
209
|
message: `${documentPath} is held by ${holder.owner} (pid ${holder.pid} on ${holder.host}), which did not save and release it within ${String(flushWaitMs)} ms.`,
|
|
201
210
|
hint: "Save or close the document in the editor and retry, or pass --force to take the lease over.",
|
|
202
211
|
details: {
|
|
203
|
-
holder,
|
|
212
|
+
holder: publicLockHolder(holder),
|
|
204
213
|
flush: "timedOut",
|
|
205
214
|
flushWaitMs
|
|
206
215
|
}
|
|
@@ -220,7 +229,7 @@ const acquireEditorLease = async ({ documentPath, owner, txId = `editor-${random
|
|
|
220
229
|
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
221
230
|
message: `A write to ${documentPath} is waiting to take the lease.`,
|
|
222
231
|
hint: "Retry after it commits, then reload the document.",
|
|
223
|
-
details: { pending }
|
|
232
|
+
details: { pending: pending.map(publicFlushRequest) }
|
|
224
233
|
}));
|
|
225
234
|
return await acquireLease({
|
|
226
235
|
documentPath,
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PublicLockHolder } from "./lock.js";
|
|
2
|
+
import { PublicFlushRequest } from "./editor-lease.js";
|
|
1
3
|
//#region src/errors.d.ts
|
|
2
4
|
/**
|
|
3
5
|
* Process exit classes. The numbers match the stella command line's table so
|
|
@@ -43,6 +45,14 @@ declare const FOLIO_CLI_ERROR_CODES: {
|
|
|
43
45
|
};
|
|
44
46
|
type FolioCliErrorCode = (typeof FOLIO_CLI_ERROR_CODES)[keyof typeof FOLIO_CLI_ERROR_CODES];
|
|
45
47
|
declare const exitCodeForError: (code: FolioCliErrorCode) => ExitCode;
|
|
48
|
+
/** Public error context. Lease-bearing fields cannot accept internal records. */
|
|
49
|
+
type PublicErrorDetails = {
|
|
50
|
+
readonly [key: string]: unknown;
|
|
51
|
+
readonly holder?: PublicLockHolder;
|
|
52
|
+
readonly pending?: readonly PublicFlushRequest[];
|
|
53
|
+
readonly token?: never;
|
|
54
|
+
readonly leaseToken?: never;
|
|
55
|
+
};
|
|
46
56
|
declare const FolioCliError_base: import("better-result").TaggedErrorClass<"FolioCliError">;
|
|
47
57
|
/**
|
|
48
58
|
* Every expected failure of a command or tool call. `hint` names the next
|
|
@@ -53,15 +63,15 @@ declare class FolioCliError extends FolioCliError_base<{
|
|
|
53
63
|
code: FolioCliErrorCode;
|
|
54
64
|
message: string;
|
|
55
65
|
hint?: string;
|
|
56
|
-
details?:
|
|
66
|
+
details?: PublicErrorDetails;
|
|
57
67
|
}> {}
|
|
58
68
|
type CliErrorOptions = {
|
|
59
69
|
code: FolioCliErrorCode;
|
|
60
70
|
message: string;
|
|
61
71
|
hint?: string | undefined;
|
|
62
|
-
details?:
|
|
72
|
+
details?: PublicErrorDetails;
|
|
63
73
|
};
|
|
64
74
|
/** Build a {@link FolioCliError} without spelling absent optional fields. */
|
|
65
75
|
declare const cliError: ({ code, message, hint, details }: CliErrorOptions) => FolioCliError;
|
|
66
76
|
//#endregion
|
|
67
|
-
export { EXIT_CODES, ExitCode, FOLIO_CLI_ERROR_CODES, FolioCliError, FolioCliErrorCode, cliError, exitCodeEntries, exitCodeForError };
|
|
77
|
+
export { EXIT_CODES, ExitCode, FOLIO_CLI_ERROR_CODES, FolioCliError, FolioCliErrorCode, PublicErrorDetails, cliError, exitCodeEntries, exitCodeForError };
|
package/dist/lock.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ type LockHolder = {
|
|
|
19
19
|
/** The holder saves and releases when a write asks it to (`editor-lease.ts`). */
|
|
20
20
|
acceptsFlush?: true;
|
|
21
21
|
};
|
|
22
|
+
/** Holder metadata safe to return to a caller that does not own the lease. */
|
|
23
|
+
type PublicLockHolder = Omit<LockHolder, "token"> & {
|
|
24
|
+
token?: never;
|
|
25
|
+
};
|
|
26
|
+
declare const publicLockHolder: ({ owner, pid, host, txId, acquiredAt, expiresAt, acceptsFlush }: LockHolder) => PublicLockHolder;
|
|
22
27
|
declare const lockPathFor: (documentPath: string) => string;
|
|
23
28
|
/** What is beside the document: no lock, or a lock with its parsed holder when it has one. */
|
|
24
29
|
type LeaseState = {
|
|
@@ -75,4 +80,4 @@ declare const adoptLease: (documentPath: string, token: string) => Promise<Resul
|
|
|
75
80
|
*/
|
|
76
81
|
declare const acquireLease: ({ documentPath, txId, force, owner, leaseMs, acceptsFlush, now }: AcquireLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
|
|
77
82
|
//#endregion
|
|
78
|
-
export { AcquiredLease, DEFAULT_LEASE_OWNER, LeaseState, LockHolder, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
|
|
83
|
+
export { AcquiredLease, DEFAULT_LEASE_OWNER, LeaseState, LockHolder, PublicLockHolder, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, publicLockHolder, readLease };
|
package/dist/lock.js
CHANGED
|
@@ -32,6 +32,15 @@ const DEFAULT_LEASE_OWNER = "folio-cli";
|
|
|
32
32
|
const OWNER_PATTERN = /^[\w.-]{1,64}$/u;
|
|
33
33
|
/** A lease owner is 1 to 64 letters, digits, '.', '_' or '-'. */
|
|
34
34
|
const isLeaseOwner = (value) => OWNER_PATTERN.test(value);
|
|
35
|
+
const publicLockHolder = ({ owner, pid, host, txId, acquiredAt, expiresAt, acceptsFlush }) => ({
|
|
36
|
+
owner,
|
|
37
|
+
pid,
|
|
38
|
+
host,
|
|
39
|
+
txId,
|
|
40
|
+
acquiredAt,
|
|
41
|
+
expiresAt,
|
|
42
|
+
...acceptsFlush === true && { acceptsFlush }
|
|
43
|
+
});
|
|
35
44
|
const lockPathFor = (documentPath) => path.join(path.dirname(documentPath), `.${path.basename(documentPath)}.folio-lock`);
|
|
36
45
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
46
|
const parseHolder = (text) => {
|
|
@@ -95,7 +104,7 @@ const lockedError = (documentPath, state) => cliError({
|
|
|
95
104
|
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
96
105
|
message: `${documentPath} is being written by another process${state.type === "held" ? ` (${state.holder.owner}, pid ${state.holder.pid} on ${state.holder.host})` : ""}.`,
|
|
97
106
|
hint: "Retry after it finishes, or pass --force to take the lease over.",
|
|
98
|
-
|
|
107
|
+
...state.type === "held" && { details: { holder: publicLockHolder(state.holder) } }
|
|
99
108
|
});
|
|
100
109
|
const lostError = (documentPath) => cliError({
|
|
101
110
|
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
@@ -214,7 +223,7 @@ const adoptLease = async (documentPath, token) => {
|
|
|
214
223
|
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
215
224
|
message: `${documentPath} is not held under that lease token.`,
|
|
216
225
|
hint: "The lease expired or was taken over; acquire it again and retry.",
|
|
217
|
-
|
|
226
|
+
...current.type === "held" && { details: { holder: publicLockHolder(current.holder) } }
|
|
218
227
|
}));
|
|
219
228
|
const { holder } = current;
|
|
220
229
|
const leaseMs = Math.max(Date.parse(holder.expiresAt) - Date.parse(holder.acquiredAt), 0);
|
|
@@ -260,4 +269,4 @@ const acquireLease = async ({ documentPath, txId, force, owner = DEFAULT_LEASE_O
|
|
|
260
269
|
return Result.err(lockedError(documentPath, { type: "free" }));
|
|
261
270
|
};
|
|
262
271
|
//#endregion
|
|
263
|
-
export { DEFAULT_LEASE_OWNER, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
|
|
272
|
+
export { DEFAULT_LEASE_OWNER, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, publicLockHolder, readLease };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/main-document-part.d.ts
|
|
4
|
+
/** Why a file is not a readable WordprocessingML package. */
|
|
5
|
+
declare const INVALID_PACKAGE_REASONS: {
|
|
6
|
+
readonly notZip: "notZip";
|
|
7
|
+
readonly duplicatePartName: "duplicatePartName";
|
|
8
|
+
readonly contentTypesMissing: "contentTypesMissing";
|
|
9
|
+
readonly contentTypesInvalid: "contentTypesInvalid";
|
|
10
|
+
readonly relationshipsMissing: "relationshipsMissing";
|
|
11
|
+
readonly relationshipsInvalid: "relationshipsInvalid";
|
|
12
|
+
readonly mainRelationshipMissing: "mainRelationshipMissing";
|
|
13
|
+
readonly mainRelationshipAmbiguous: "mainRelationshipAmbiguous";
|
|
14
|
+
readonly mainPartMissing: "mainPartMissing";
|
|
15
|
+
readonly mainPartContentType: "mainPartContentType";
|
|
16
|
+
readonly mainPartRoot: "mainPartRoot";
|
|
17
|
+
readonly mainPartLocation: "mainPartLocation";
|
|
18
|
+
};
|
|
19
|
+
type InvalidPackageReason = (typeof INVALID_PACKAGE_REASONS)[keyof typeof INVALID_PACKAGE_REASONS];
|
|
20
|
+
/**
|
|
21
|
+
* Refuse, as `invalid_document`, a file that is not a WordprocessingML
|
|
22
|
+
* package with a main document part folio reads.
|
|
23
|
+
*/
|
|
24
|
+
declare const checkWordprocessingPackage: (filePath: string, bytes: Uint8Array) => Promise<Result<void, FolioCliError>>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { INVALID_PACKAGE_REASONS, InvalidPackageReason, checkWordprocessingPackage };
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
5
|
+
import JSZip from "jszip";
|
|
6
|
+
//#region src/main-document-part.ts
|
|
7
|
+
/**
|
|
8
|
+
* Whether a file is a WordprocessingML package at all, decided the way the
|
|
9
|
+
* Open Packaging Conventions say to find its main part: the package
|
|
10
|
+
* relationship of the office-document type names the part, the part exists,
|
|
11
|
+
* `[Content_Types].xml` gives it a WordprocessingML main-document type, and
|
|
12
|
+
* its root is a `document` in a WordprocessingML namespace.
|
|
13
|
+
*
|
|
14
|
+
* The folio parser reads whatever `.docx` it is handed and reports a missing
|
|
15
|
+
* main part as a warning, so an archive holding no document opened as an
|
|
16
|
+
* empty one. Checked where a file enters the CLI, a ZIP of unrelated entries
|
|
17
|
+
* is refused before a read reports it as an empty document or a save commits
|
|
18
|
+
* it over a real one.
|
|
19
|
+
*
|
|
20
|
+
* Every name is resolved by namespace URI, never by prefix, and both the
|
|
21
|
+
* Transitional and the Strict relationship and WordprocessingML namespaces are
|
|
22
|
+
* accepted.
|
|
23
|
+
*/
|
|
24
|
+
const CONTENT_TYPES_PART = "[Content_Types].xml";
|
|
25
|
+
const PACKAGE_RELATIONSHIPS_PART = "_rels/.rels";
|
|
26
|
+
/** The one main part the folio parser reads. */
|
|
27
|
+
const READ_MAIN_DOCUMENT_PART = "word/document.xml";
|
|
28
|
+
const CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types";
|
|
29
|
+
const PACKAGE_RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
30
|
+
/** The office-document relationship type: Transitional, then Strict. */
|
|
31
|
+
const OFFICE_DOCUMENT_RELATIONSHIP_TYPES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"]);
|
|
32
|
+
const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
|
|
33
|
+
/** A document, a template, and their macro-enabled forms; Strict uses the same types. */
|
|
34
|
+
const MAIN_DOCUMENT_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
35
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
|
|
36
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml",
|
|
37
|
+
"application/vnd.ms-word.document.macroEnabled.main+xml",
|
|
38
|
+
"application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
|
|
39
|
+
]);
|
|
40
|
+
/** `[Content_Types].xml` and `_rels/.rels` are indexes; this bounds their inflation. */
|
|
41
|
+
const MAX_PACKAGE_INDEX_BYTES = 1024 * 1024;
|
|
42
|
+
/** How much of the main part is inflated to find its root element. */
|
|
43
|
+
const MAX_ROOT_SCAN_BYTES = 64 * 1024;
|
|
44
|
+
/** Why a file is not a readable WordprocessingML package. */
|
|
45
|
+
const INVALID_PACKAGE_REASONS = {
|
|
46
|
+
notZip: "notZip",
|
|
47
|
+
duplicatePartName: "duplicatePartName",
|
|
48
|
+
contentTypesMissing: "contentTypesMissing",
|
|
49
|
+
contentTypesInvalid: "contentTypesInvalid",
|
|
50
|
+
relationshipsMissing: "relationshipsMissing",
|
|
51
|
+
relationshipsInvalid: "relationshipsInvalid",
|
|
52
|
+
mainRelationshipMissing: "mainRelationshipMissing",
|
|
53
|
+
mainRelationshipAmbiguous: "mainRelationshipAmbiguous",
|
|
54
|
+
mainPartMissing: "mainPartMissing",
|
|
55
|
+
mainPartContentType: "mainPartContentType",
|
|
56
|
+
mainPartRoot: "mainPartRoot",
|
|
57
|
+
mainPartLocation: "mainPartLocation"
|
|
58
|
+
};
|
|
59
|
+
const refuse = (reason, message) => Result.err({
|
|
60
|
+
reason,
|
|
61
|
+
message
|
|
62
|
+
});
|
|
63
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
const orderedParser = new XMLParser({
|
|
65
|
+
preserveOrder: true,
|
|
66
|
+
ignoreAttributes: false,
|
|
67
|
+
attributeNamePrefix: "",
|
|
68
|
+
parseAttributeValue: false,
|
|
69
|
+
parseTagValue: false,
|
|
70
|
+
processEntities: false,
|
|
71
|
+
ignoreDeclaration: true,
|
|
72
|
+
ignorePiTags: true
|
|
73
|
+
});
|
|
74
|
+
const splitName = (qualified) => {
|
|
75
|
+
const colon = qualified.indexOf(":");
|
|
76
|
+
return colon === -1 ? {
|
|
77
|
+
prefix: "",
|
|
78
|
+
localName: qualified
|
|
79
|
+
} : {
|
|
80
|
+
prefix: qualified.slice(0, colon),
|
|
81
|
+
localName: qualified.slice(colon + 1)
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const stringAttributes = (value) => {
|
|
85
|
+
const attributes = {};
|
|
86
|
+
if (!isRecord(value)) return attributes;
|
|
87
|
+
for (const [name, attribute] of Object.entries(value)) if (typeof attribute === "string") attributes[name] = attribute;
|
|
88
|
+
return attributes;
|
|
89
|
+
};
|
|
90
|
+
/** The element children of one ordered node list, with namespaces in scope resolved. */
|
|
91
|
+
const elementsOf = (nodes, scope) => {
|
|
92
|
+
if (!Array.isArray(nodes)) return [];
|
|
93
|
+
const elements = [];
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
if (!isRecord(node)) continue;
|
|
96
|
+
const name = Object.keys(node).find((key) => key !== ":@" && key !== "#text");
|
|
97
|
+
if (name === void 0) continue;
|
|
98
|
+
const attributes = stringAttributes(node[":@"]);
|
|
99
|
+
const inner = new Map(scope);
|
|
100
|
+
for (const [attribute, value] of Object.entries(attributes)) if (attribute === "xmlns") inner.set("", value);
|
|
101
|
+
else if (attribute.startsWith("xmlns:")) inner.set(attribute.slice(6), value);
|
|
102
|
+
const { prefix, localName } = splitName(name);
|
|
103
|
+
elements.push({
|
|
104
|
+
namespace: inner.get(prefix),
|
|
105
|
+
localName,
|
|
106
|
+
attributes,
|
|
107
|
+
children: elementsOf(node[name], inner)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return elements;
|
|
111
|
+
};
|
|
112
|
+
/** The root element of a well-formed XML part, or `null`. */
|
|
113
|
+
const parseRoot = (xml) => {
|
|
114
|
+
if (/<!DOCTYPE/iu.test(xml) || XMLValidator.validate(xml) !== true) return null;
|
|
115
|
+
const parsed = orderedParser.parse(xml);
|
|
116
|
+
const [root] = elementsOf(parsed, /* @__PURE__ */ new Map());
|
|
117
|
+
return root ?? null;
|
|
118
|
+
};
|
|
119
|
+
const childrenNamed = (element, namespace, localName) => element.children.filter((child) => child.namespace === namespace && child.localName === localName);
|
|
120
|
+
/** Part names compare case-insensitively (ECMA-376 Part 2, 9.1.1.1). */
|
|
121
|
+
const partKey = (name) => name.toLowerCase();
|
|
122
|
+
/** Inflate at most `limit` bytes of an entry; `truncated` when it holds more. */
|
|
123
|
+
const inflatePrefix = (entry, limit) => new Promise((resolve, reject) => {
|
|
124
|
+
const decoder = new TextDecoder();
|
|
125
|
+
let text = "";
|
|
126
|
+
let size = 0;
|
|
127
|
+
const stream = entry.internalStream("uint8array");
|
|
128
|
+
stream.on("data", (chunk) => {
|
|
129
|
+
const room = limit - size;
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
text += decoder.decode(chunk.subarray(0, Math.max(0, room)), { stream: true });
|
|
132
|
+
if (size > limit) {
|
|
133
|
+
stream.pause();
|
|
134
|
+
resolve({
|
|
135
|
+
text: text + decoder.decode(),
|
|
136
|
+
truncated: true
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}).on("error", reject).on("end", () => resolve({
|
|
140
|
+
text: text + decoder.decode(),
|
|
141
|
+
truncated: false
|
|
142
|
+
})).resume();
|
|
143
|
+
});
|
|
144
|
+
const readIndexPart = async ({ parts, name, missing, invalid }) => {
|
|
145
|
+
const entry = parts.get(partKey(name));
|
|
146
|
+
if (entry === void 0) return refuse(missing, `it has no ${name}.`);
|
|
147
|
+
const inflated = await Result.tryPromise({
|
|
148
|
+
try: () => inflatePrefix(entry, MAX_PACKAGE_INDEX_BYTES),
|
|
149
|
+
catch: () => ({
|
|
150
|
+
reason: invalid,
|
|
151
|
+
message: `its ${name} cannot be inflated.`
|
|
152
|
+
})
|
|
153
|
+
});
|
|
154
|
+
if (inflated.isErr()) return Result.err(inflated.error);
|
|
155
|
+
if (inflated.value.truncated) return refuse(invalid, `its ${name} is over ${String(MAX_PACKAGE_INDEX_BYTES)} bytes.`);
|
|
156
|
+
const root = parseRoot(inflated.value.text);
|
|
157
|
+
return root === null ? refuse(invalid, `its ${name} is not well-formed XML.`) : Result.ok(root);
|
|
158
|
+
};
|
|
159
|
+
/** The part a package-relative relationship target names, without its leading `/`. */
|
|
160
|
+
const resolvePackageTarget = (target) => {
|
|
161
|
+
const decoded = Result.try({
|
|
162
|
+
try: () => decodeURIComponent(target),
|
|
163
|
+
catch: () => null
|
|
164
|
+
});
|
|
165
|
+
if (decoded.isErr()) return null;
|
|
166
|
+
const resolved = path.posix.normalize(path.posix.join("/", decoded.value));
|
|
167
|
+
return resolved === "/" ? null : resolved.slice(1);
|
|
168
|
+
};
|
|
169
|
+
const mainPartName = (relationships) => {
|
|
170
|
+
if (relationships.namespace !== PACKAGE_RELATIONSHIPS_NAMESPACE || relationships.localName !== "Relationships") return refuse(INVALID_PACKAGE_REASONS.relationshipsInvalid, `its ${PACKAGE_RELATIONSHIPS_PART} is not a relationships part.`);
|
|
171
|
+
const main = childrenNamed(relationships, PACKAGE_RELATIONSHIPS_NAMESPACE, "Relationship").filter(({ attributes }) => OFFICE_DOCUMENT_RELATIONSHIP_TYPES.has(attributes["Type"] ?? "") && attributes["TargetMode"] !== "External");
|
|
172
|
+
const [only, ...others] = main;
|
|
173
|
+
if (only === void 0) return refuse(INVALID_PACKAGE_REASONS.mainRelationshipMissing, "no package relationship names a main document part.");
|
|
174
|
+
if (others.length > 0) return refuse(INVALID_PACKAGE_REASONS.mainRelationshipAmbiguous, `${String(main.length)} package relationships name a main document part.`);
|
|
175
|
+
const part = resolvePackageTarget(only.attributes["Target"] ?? "");
|
|
176
|
+
return part === null ? refuse(INVALID_PACKAGE_REASONS.relationshipsInvalid, "the main document relationship has no usable target.") : Result.ok(part);
|
|
177
|
+
};
|
|
178
|
+
/** An `Override` for the part wins over the `Default` for its extension. */
|
|
179
|
+
const contentTypeOf = ({ contentTypes, part }) => {
|
|
180
|
+
const partName = partKey(`/${part}`);
|
|
181
|
+
const override = childrenNamed(contentTypes, CONTENT_TYPES_NAMESPACE, "Override").find(({ attributes }) => partKey(attributes["PartName"] ?? "") === partName);
|
|
182
|
+
if (override !== void 0) return override.attributes["ContentType"];
|
|
183
|
+
const extension = partKey(path.posix.extname(part).slice(1));
|
|
184
|
+
return childrenNamed(contentTypes, CONTENT_TYPES_NAMESPACE, "Default").find(({ attributes }) => partKey(attributes["Extension"] ?? "") === extension)?.attributes["ContentType"];
|
|
185
|
+
};
|
|
186
|
+
/** The first element's name and namespace declarations, read from its start tag. */
|
|
187
|
+
const rootOfPrefix = (xml) => {
|
|
188
|
+
const body = xml.replace(/^?(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->)*/u, "");
|
|
189
|
+
const match = /^<([A-Za-z_][\w.:-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/u.exec(body);
|
|
190
|
+
if (match === null) return null;
|
|
191
|
+
const [, name = "", attributeText = ""] = match;
|
|
192
|
+
const scope = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const [, attribute = "", double, single] of attributeText.matchAll(/([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)) {
|
|
194
|
+
const value = double ?? single ?? "";
|
|
195
|
+
if (attribute === "xmlns") scope.set("", value);
|
|
196
|
+
else if (attribute.startsWith("xmlns:")) scope.set(attribute.slice(6), value);
|
|
197
|
+
}
|
|
198
|
+
const { prefix, localName } = splitName(name);
|
|
199
|
+
return {
|
|
200
|
+
namespace: scope.get(prefix),
|
|
201
|
+
localName,
|
|
202
|
+
attributes: {},
|
|
203
|
+
children: []
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
const ZIP_END_SIGNATURE = 101010256;
|
|
207
|
+
const ZIP64_END_SIGNATURE = 101075792;
|
|
208
|
+
const ZIP64_LOCATOR_SIGNATURE = 117853008;
|
|
209
|
+
const ZIP_CENTRAL_MEMBER_SIGNATURE = 33639248;
|
|
210
|
+
const ZIP_END_LENGTH = 22;
|
|
211
|
+
const ZIP_CENTRAL_MEMBER_LENGTH = 46;
|
|
212
|
+
const ZIP_MAX_COMMENT_LENGTH = 65535;
|
|
213
|
+
/** Read central-directory names before JSZip stores members by name. */
|
|
214
|
+
const checkRawMemberNames = (bytes) => {
|
|
215
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
216
|
+
let end = -1;
|
|
217
|
+
for (let offset = bytes.length - ZIP_END_LENGTH; offset >= Math.max(0, bytes.length - ZIP_END_LENGTH - ZIP_MAX_COMMENT_LENGTH); offset -= 1) if (view.getUint32(offset, true) === ZIP_END_SIGNATURE && offset + ZIP_END_LENGTH + view.getUint16(offset + 20, true) === bytes.length) {
|
|
218
|
+
end = offset;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (end < 0) return refuse(INVALID_PACKAGE_REASONS.notZip, "it has no readable ZIP directory.");
|
|
222
|
+
let count = view.getUint16(end + 10, true);
|
|
223
|
+
let size = view.getUint32(end + 12, true);
|
|
224
|
+
let directoryEnd = end;
|
|
225
|
+
if (count === 65535 || size === 4294967295 || view.getUint32(end + 16, true) === 4294967295) {
|
|
226
|
+
const locator = end - 20;
|
|
227
|
+
if (locator < 0 || view.getUint32(locator, true) !== ZIP64_LOCATOR_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP64 directory is missing.");
|
|
228
|
+
const zip64Offset = Number(view.getBigUint64(locator + 8, true));
|
|
229
|
+
if (!Number.isSafeInteger(zip64Offset) || zip64Offset < 0 || zip64Offset + 56 > locator || view.getUint32(zip64Offset, true) !== ZIP64_END_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP64 directory is invalid.");
|
|
230
|
+
count = Number(view.getBigUint64(zip64Offset + 32, true));
|
|
231
|
+
size = Number(view.getBigUint64(zip64Offset + 40, true));
|
|
232
|
+
directoryEnd = zip64Offset;
|
|
233
|
+
}
|
|
234
|
+
if (!Number.isSafeInteger(count) || !Number.isSafeInteger(size) || size > directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
|
|
235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
236
|
+
let offset = directoryEnd - size;
|
|
237
|
+
for (let index = 0; index < count; index += 1) {
|
|
238
|
+
if (offset + ZIP_CENTRAL_MEMBER_LENGTH > directoryEnd || view.getUint32(offset, true) !== ZIP_CENTRAL_MEMBER_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
|
|
239
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
240
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
241
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
242
|
+
const next = offset + ZIP_CENTRAL_MEMBER_LENGTH + nameLength + extraLength + commentLength;
|
|
243
|
+
if (next > directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
|
|
244
|
+
const name = Buffer.from(bytes.subarray(offset + ZIP_CENTRAL_MEMBER_LENGTH, offset + ZIP_CENTRAL_MEMBER_LENGTH + nameLength)).toString("hex");
|
|
245
|
+
if (seen.has(name)) return refuse(INVALID_PACKAGE_REASONS.duplicatePartName, "two ZIP entries have the same name.");
|
|
246
|
+
seen.add(name);
|
|
247
|
+
offset = next;
|
|
248
|
+
}
|
|
249
|
+
if (offset !== directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
|
|
250
|
+
return Result.ok();
|
|
251
|
+
};
|
|
252
|
+
const checkPackage = async (bytes) => {
|
|
253
|
+
const rawNames = checkRawMemberNames(bytes);
|
|
254
|
+
if (rawNames.isErr()) return rawNames;
|
|
255
|
+
const zip = await Result.tryPromise({
|
|
256
|
+
try: () => JSZip.loadAsync(bytes),
|
|
257
|
+
catch: () => ({
|
|
258
|
+
reason: INVALID_PACKAGE_REASONS.notZip,
|
|
259
|
+
message: "it is not a readable ZIP archive."
|
|
260
|
+
})
|
|
261
|
+
});
|
|
262
|
+
if (zip.isErr()) return Result.err(zip.error);
|
|
263
|
+
const parts = /* @__PURE__ */ new Map();
|
|
264
|
+
for (const [name, entry] of Object.entries(zip.value.files)) {
|
|
265
|
+
if (entry.dir) continue;
|
|
266
|
+
const key = partKey(name.replace(/^\//u, ""));
|
|
267
|
+
if (parts.has(key)) return refuse(INVALID_PACKAGE_REASONS.duplicatePartName, `two entries share the part name ${name}.`);
|
|
268
|
+
parts.set(key, entry);
|
|
269
|
+
}
|
|
270
|
+
const contentTypes = await readIndexPart({
|
|
271
|
+
parts,
|
|
272
|
+
name: CONTENT_TYPES_PART,
|
|
273
|
+
missing: INVALID_PACKAGE_REASONS.contentTypesMissing,
|
|
274
|
+
invalid: INVALID_PACKAGE_REASONS.contentTypesInvalid
|
|
275
|
+
});
|
|
276
|
+
if (contentTypes.isErr()) return Result.err(contentTypes.error);
|
|
277
|
+
if (contentTypes.value.namespace !== CONTENT_TYPES_NAMESPACE || contentTypes.value.localName !== "Types") return refuse(INVALID_PACKAGE_REASONS.contentTypesInvalid, `its ${CONTENT_TYPES_PART} is not a content types part.`);
|
|
278
|
+
const relationships = await readIndexPart({
|
|
279
|
+
parts,
|
|
280
|
+
name: PACKAGE_RELATIONSHIPS_PART,
|
|
281
|
+
missing: INVALID_PACKAGE_REASONS.relationshipsMissing,
|
|
282
|
+
invalid: INVALID_PACKAGE_REASONS.relationshipsInvalid
|
|
283
|
+
});
|
|
284
|
+
if (relationships.isErr()) return Result.err(relationships.error);
|
|
285
|
+
const part = mainPartName(relationships.value);
|
|
286
|
+
if (part.isErr()) return Result.err(part.error);
|
|
287
|
+
const entry = parts.get(partKey(part.value));
|
|
288
|
+
if (entry === void 0) return refuse(INVALID_PACKAGE_REASONS.mainPartMissing, `its main document part /${part.value} is missing.`);
|
|
289
|
+
const contentType = contentTypeOf({
|
|
290
|
+
contentTypes: contentTypes.value,
|
|
291
|
+
part: part.value
|
|
292
|
+
});
|
|
293
|
+
if (contentType === void 0 || !MAIN_DOCUMENT_CONTENT_TYPES.has(contentType)) return refuse(INVALID_PACKAGE_REASONS.mainPartContentType, `its main part /${part.value} has content type ${contentType ?? "(none)"}, not a WordprocessingML document.`);
|
|
294
|
+
const prefix = await Result.tryPromise({
|
|
295
|
+
try: () => inflatePrefix(entry, MAX_ROOT_SCAN_BYTES),
|
|
296
|
+
catch: () => ({
|
|
297
|
+
reason: INVALID_PACKAGE_REASONS.mainPartRoot,
|
|
298
|
+
message: `its main part /${part.value} cannot be inflated.`
|
|
299
|
+
})
|
|
300
|
+
});
|
|
301
|
+
if (prefix.isErr()) return Result.err(prefix.error);
|
|
302
|
+
const root = rootOfPrefix(prefix.value.text);
|
|
303
|
+
if (root === null || root.localName !== "document" || !WORDPROCESSINGML_NAMESPACES.has(root.namespace ?? "")) return refuse(INVALID_PACKAGE_REASONS.mainPartRoot, `its main part /${part.value} is not a WordprocessingML document.`);
|
|
304
|
+
if (partKey(part.value) !== partKey(READ_MAIN_DOCUMENT_PART)) return refuse(INVALID_PACKAGE_REASONS.mainPartLocation, `its main document is /${part.value}; folio reads it only at /${READ_MAIN_DOCUMENT_PART}.`);
|
|
305
|
+
return Result.ok();
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Refuse, as `invalid_document`, a file that is not a WordprocessingML
|
|
309
|
+
* package with a main document part folio reads.
|
|
310
|
+
*/
|
|
311
|
+
const checkWordprocessingPackage = async (filePath, bytes) => {
|
|
312
|
+
const checked = await checkPackage(bytes);
|
|
313
|
+
if (checked.isOk()) return Result.ok();
|
|
314
|
+
const { reason, message } = checked.error;
|
|
315
|
+
return Result.err(cliError({
|
|
316
|
+
code: FOLIO_CLI_ERROR_CODES.invalidDocument,
|
|
317
|
+
message: `${filePath} is not a .docx package: ${message}`,
|
|
318
|
+
hint: "Pass a .docx saved by a word processor or by folio.",
|
|
319
|
+
details: { reason }
|
|
320
|
+
}));
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
323
|
+
export { INVALID_PACKAGE_REASONS, checkWordprocessingPackage };
|
package/dist/output.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FolioCliError, FolioCliErrorCode } from "./errors.js";
|
|
1
|
+
import { FolioCliError, FolioCliErrorCode, PublicErrorDetails } from "./errors.js";
|
|
2
2
|
//#region src/output.d.ts
|
|
3
3
|
declare const OUTPUT_FORMATS: readonly ["json", "text"];
|
|
4
4
|
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
@@ -7,7 +7,7 @@ type FailureBody = {
|
|
|
7
7
|
code: FolioCliErrorCode;
|
|
8
8
|
message: string;
|
|
9
9
|
hint?: string;
|
|
10
|
-
details?:
|
|
10
|
+
details?: PublicErrorDetails;
|
|
11
11
|
};
|
|
12
12
|
type Envelope = {
|
|
13
13
|
ok: true;
|
package/dist/package-parts.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ declare const nextRevisionIdSeed: (bytes: Uint8Array) => Promise<Result<number,
|
|
|
17
17
|
* Check what a save wrote before it replaces anything: the package opens,
|
|
18
18
|
* every changed XML part is well formed, every relationship id a changed
|
|
19
19
|
* part references resolves, every internal target a changed `.rels` part
|
|
20
|
-
* names exists,
|
|
20
|
+
* names exists, the package still has the main document part folio reads,
|
|
21
|
+
* and it parses as a document again.
|
|
21
22
|
*/
|
|
22
23
|
declare const checkPackageIntegrity: (bytes: Uint8Array<ArrayBuffer>, changedParts: readonly ChangedPart[]) => Promise<Result<void, FolioCliError>>;
|
|
23
24
|
//#endregion
|
package/dist/package-parts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { checkWordprocessingPackage } from "./main-document-part.js";
|
|
2
3
|
import { Result } from "better-result";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { FolioDocxReviewer } from "@stll/folio-core/server";
|
|
@@ -147,7 +148,8 @@ const checkChangedPart = async ({ zip, parts, part }) => {
|
|
|
147
148
|
* Check what a save wrote before it replaces anything: the package opens,
|
|
148
149
|
* every changed XML part is well formed, every relationship id a changed
|
|
149
150
|
* part references resolves, every internal target a changed `.rels` part
|
|
150
|
-
* names exists,
|
|
151
|
+
* names exists, the package still has the main document part folio reads,
|
|
152
|
+
* and it parses as a document again.
|
|
151
153
|
*/
|
|
152
154
|
const checkPackageIntegrity = async (bytes, changedParts) => {
|
|
153
155
|
const zip = await loadZip(bytes);
|
|
@@ -162,6 +164,8 @@ const checkPackageIntegrity = async (bytes, changedParts) => {
|
|
|
162
164
|
});
|
|
163
165
|
if (problem !== null) return Result.err(integrityError(`The saved package failed validation: ${problem}`));
|
|
164
166
|
}
|
|
167
|
+
const mainPart = await checkWordprocessingPackage("The saved package", bytes);
|
|
168
|
+
if (mainPart.isErr()) return Result.err(integrityError(mainPart.error.message));
|
|
165
169
|
const reopened = await Result.tryPromise({
|
|
166
170
|
try: () => FolioDocxReviewer.fromBuffer(bytes.slice().buffer),
|
|
167
171
|
catch: (error) => integrityError(`The saved package does not reopen: ${error instanceof Error ? error.message : String(error)}`)
|
package/dist/package.js
CHANGED
package/dist/preview-commands.js
CHANGED
|
@@ -210,10 +210,7 @@ const runServe = async (rest, io) => {
|
|
|
210
210
|
documentPath: input,
|
|
211
211
|
port
|
|
212
212
|
});
|
|
213
|
-
if (started.isErr()) return emit(io, format.value,
|
|
214
|
-
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
215
|
-
message: started.error.message
|
|
216
|
-
})));
|
|
213
|
+
if (started.isErr()) return emit(io, format.value, started);
|
|
217
214
|
const exit = emit(io, format.value, Result.ok({
|
|
218
215
|
url: started.value.url,
|
|
219
216
|
path: path.resolve(input)
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
1
2
|
import { Result } from "better-result";
|
|
2
3
|
//#region src/serve.d.ts
|
|
3
4
|
declare const PREVIEW_HOST = "127.0.0.1";
|
|
@@ -12,6 +13,6 @@ type PreviewServer = {
|
|
|
12
13
|
close: () => Promise<void>;
|
|
13
14
|
};
|
|
14
15
|
/** Serve the preview until {@link PreviewServer.close}. */
|
|
15
|
-
declare const startPreviewServer: ({ documentPath, port }: PreviewServerOptions) => Promise<Result<PreviewServer,
|
|
16
|
+
declare const startPreviewServer: ({ documentPath, port }: PreviewServerOptions) => Promise<Result<PreviewServer, FolioCliError>>;
|
|
16
17
|
//#endregion
|
|
17
18
|
export { PREVIEW_HOST, PreviewServer, PreviewServerOptions, startPreviewServer };
|
package/dist/serve.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readDocumentFile } from "./document.js";
|
|
2
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
3
|
import { latestCommitFor } from "./journal.js";
|
|
3
4
|
import { buildDisplayList, displayListHtml } from "./render.js";
|
|
4
5
|
import { SIDECAR_DIRECTORY } from "./sidecar.js";
|
|
@@ -81,7 +82,7 @@ const DOCUMENT_CSP = "default-src 'none'; img-src data:; font-src data:; style-s
|
|
|
81
82
|
/** Serve the preview until {@link PreviewServer.close}. */
|
|
82
83
|
const startPreviewServer = async ({ documentPath, port }) => {
|
|
83
84
|
const initial = await readDocumentFile(documentPath);
|
|
84
|
-
if (initial.isErr()) return Result.err(
|
|
85
|
+
if (initial.isErr()) return Result.err(initial.error);
|
|
85
86
|
const realPath = initial.value.path;
|
|
86
87
|
const title = path.basename(realPath);
|
|
87
88
|
const prefix = `/${randomBytes(24).toString("base64url")}/`;
|
|
@@ -215,7 +216,10 @@ const startPreviewServer = async ({ documentPath, port }) => {
|
|
|
215
216
|
if ((await Result.tryPromise(() => new Promise((resolve, reject) => {
|
|
216
217
|
server.once("error", reject);
|
|
217
218
|
server.listen(port, "127.0.0.1", () => resolve());
|
|
218
|
-
}))).isErr()) return Result.err(
|
|
219
|
+
}))).isErr()) return Result.err(cliError({
|
|
220
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
221
|
+
message: `Cannot listen on ${PREVIEW_HOST}:${String(port)}.`
|
|
222
|
+
}));
|
|
219
223
|
const address = server.address();
|
|
220
224
|
boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
221
225
|
const watchers = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/folio-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Command line and stdio MCP server for reading, reviewing, and redlining .docx files with folio: tracked-change edits, comments, and comparisons written atomically with version preconditions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
"@fontsource/source-sans-3": "^5.2.9",
|
|
71
71
|
"@fontsource/tinos": "^5.2.7",
|
|
72
72
|
"@modelcontextprotocol/server": "2.0.0",
|
|
73
|
-
"@stll/folio-agents": "^0.
|
|
74
|
-
"@stll/folio-core": "^0.
|
|
73
|
+
"@stll/folio-agents": "^0.15.0",
|
|
74
|
+
"@stll/folio-core": "^0.53.0",
|
|
75
75
|
"better-result": "3.0.1",
|
|
76
76
|
"fast-xml-parser": "^5.10.1",
|
|
77
77
|
"jszip": "3.10.1"
|