@jwilleke/ngdpbase-types 5.26.0 → 5.27.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 (36) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +5 -2
  2. package/lib/packages/ngdpbase-types/testing.d.ts +7 -3
  3. package/lib/packages/ngdpbase-types/testing.js +7 -3
  4. package/lib/src/converters/DocxConverter.d.ts +30 -0
  5. package/lib/src/converters/MarkdownConverter.d.ts +31 -0
  6. package/lib/src/export/ChromeServicePdfProvider.d.ts +52 -0
  7. package/lib/src/export/ChromeServicePdfProvider.js +153 -0
  8. package/lib/src/export/LocalChromePdfProvider.d.ts +25 -0
  9. package/lib/src/export/LocalChromePdfProvider.js +33 -0
  10. package/lib/src/export/PdfProvider.d.ts +51 -0
  11. package/lib/src/export/PdfProvider.js +37 -0
  12. package/lib/src/export/pdfEngine.d.ts +113 -0
  13. package/lib/src/export/pdfEngine.js +195 -0
  14. package/lib/src/ice/converterShim.d.ts +5 -0
  15. package/lib/src/ice/fileHandling.d.ts +30 -0
  16. package/lib/src/ice/ledger.d.ts +37 -0
  17. package/lib/src/ice/runImport.d.ts +51 -0
  18. package/lib/src/ice/types.d.ts +262 -0
  19. package/lib/src/mail/MailProvider.d.ts +15 -0
  20. package/lib/src/mail/NodemailerMailProvider.d.ts +1 -1
  21. package/lib/src/managers/AddonsManager.d.ts +18 -2
  22. package/lib/src/managers/EmailManager.d.ts +32 -5
  23. package/lib/src/managers/ExportManager.d.ts +62 -0
  24. package/lib/src/managers/ExportManager.js +147 -2
  25. package/lib/src/managers/ImportManager.d.ts +590 -0
  26. package/lib/src/managers/PageManager.d.ts +45 -1
  27. package/lib/src/security/PolicyInformationPoint.d.ts +25 -2
  28. package/lib/src/types/Config.d.ts +2 -0
  29. package/lib/src/utils/addonHost.d.ts +23 -2
  30. package/lib/src/utils/addonHost.js +25 -2
  31. package/lib/src/utils/frontmatterAccess.d.ts +54 -11
  32. package/lib/src/utils/importRunSummary.d.ts +64 -0
  33. package/lib/src/utils/ncmNotify.d.ts +30 -0
  34. package/lib/src/utils/privateStoreImport.d.ts +58 -0
  35. package/lib/src/utils/zipArchive.d.ts +71 -0
  36. package/package.json +1 -1
@@ -209,6 +209,12 @@ declare class PolicyInformationPoint extends BaseManager {
209
209
  * none when it is not running.
210
210
  */
211
211
  subjectEnabledAddons(ctx: ActorContext): Promise<string[]>;
212
+ /**
213
+ * {@link subjectEnabledAddons} for a page decision (#1964): none for no
214
+ * subject, and none when the lookup fails — an `addon:<slug>` entry then
215
+ * matches nobody, never everyone.
216
+ */
217
+ private enabledAddonsOf;
212
218
  private userManager;
213
219
  private roleManager;
214
220
  /**
@@ -326,7 +332,9 @@ declare class PolicyInformationPoint extends BaseManager {
326
332
  * - __Tier 0.5, author-lock__, `edit` only. It only denies: an author, or a
327
333
  * subject holding `admin-system` (#1431 7b), falls through to Tier 1+.
328
334
  * - __Tier 1, frontmatter__ `access[action]` / `audience`. Page-level, and
329
- * overrides global policy when it states a rule (#1054).
335
+ * overrides global policy when it states a rule (#1054). An `audience`
336
+ * covers every action, and a subject holding `admin-system` is its one
337
+ * exception; `access` has none (#1964).
330
338
  * - __Tier 2.__ For a share visitor the share IS the policy (#1222); for
331
339
  * anyone else, global policy.
332
340
  * - __Default deny.__ Nothing below Tier 2 grants — JSPWiki's page-body ACL
@@ -439,10 +447,25 @@ declare class PolicyInformationPoint extends BaseManager {
439
447
  */
440
448
  canUserAccessMediaItem(userContext: UserContext | null | undefined, item: MediaItem): Promise<boolean>;
441
449
  /**
442
- * Check front matter audience / access fields (Tier 1.5).
450
+ * Check front matter audience / access fields (Tier 1).
443
451
  * Returns a decision object; decided=false means no front matter restriction — fall through.
452
+ *
453
+ * An audience that leaves the subject out refuses every action, unless the
454
+ * subject holds `admin-system` (#1964, operator 2026-10-23): asked only
455
+ * then. `access[action]` has no such exception.
444
456
  */
445
457
  private checkFrontmatterAccess;
458
+ /**
459
+ * Does the page's `audience` admit this subject (#1964)? True when the page
460
+ * names no audience, the subject matches it, or the subject holds
461
+ * `admin-system`. The same rule Tier 1 applies, asked by PageManager's
462
+ * write door, so a write cannot reach a page its writer is outside the
463
+ * audience of — whatever route it came by. Records nothing; the door does.
464
+ *
465
+ * @param userContext - the subject, as given
466
+ * @param metadata - the page's current frontmatter
467
+ */
468
+ audienceAdmits(userContext: UserContext | null | undefined, metadata: PageFrontmatter | null | undefined): Promise<boolean>;
446
469
  /**
447
470
  * Record/audit an access decision.
448
471
  * Accepts either a single object or positional args for backward compatibility.
@@ -121,6 +121,8 @@ export interface WikiConfig {
121
121
  * counting every failure inside it (#1916). Default: 60.
122
122
  */
123
123
  'ngdpbase.mail.failure-notice.window-minutes': number;
124
+ /** The most a message's attachments may total, in bytes (#1974). Default: 10485760. */
125
+ 'ngdpbase.mail.attachments.max-bytes': number;
124
126
  /**
125
127
  * Anti-spam: max submissions per IP per `window-minutes` window before
126
128
  * the 429 trips (#670 Phase E). Default: 5.
@@ -15,6 +15,8 @@
15
15
  * the add-on (#1973);
16
16
  * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
17
  * bound to its slug, so it can never set another add-on's;
18
+ * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
+ * engine under this add-on's slug, and only the names its manifest declares;
18
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
21
  * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
22
  * are core's own pure functions, handed on unchanged, so an add-on never
@@ -36,6 +38,8 @@ import { createGuardedElasticsearchClient, refusedNodeMessage } from '../http/gu
36
38
  import { isAtLeast } from './version.js';
37
39
  import { recordAuditEvent } from './auditEvents.js';
38
40
  import { addonAuditEventName } from './auditEventNames.js';
41
+ import type { IceReceiver, IceShim } from '../ice/types.js';
42
+ import type { AddonManifest } from '../managers/AddonsManager.js';
39
43
  /** The values core offers an add-on, under the names they were imported as. */
40
44
  export interface AddonHost {
41
45
  readonly ApiContext: typeof ApiContext;
@@ -76,7 +80,21 @@ export interface AddonHost {
76
80
  readonly refusedNodeMessage: typeof refusedNodeMessage;
77
81
  /** Whether an `x.y.z` version is at least another; throws on a version that is not `x.y.z`. */
78
82
  readonly isAtLeast: typeof isAtLeast;
83
+ /**
84
+ * #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
85
+ * in `register`. Refused when the manifest's `shims` does not name it.
86
+ * Returns the shim's id; site configuration switches it on and off.
87
+ */
88
+ readonly registerShim: (shim: IceShim) => string;
89
+ /**
90
+ * #1979 (ICE, D4): register a receiver — your manager's own door for what
91
+ * shims produce — as `<slug>.<receiver.name>`. Refused when the manifest's
92
+ * `receivers` does not name it. Returns the receiver's id.
93
+ */
94
+ readonly registerReceiver: (receiver: IceReceiver) => string;
79
95
  }
96
+ /** What the host may register for an add-on: the shim and receiver names its manifest declares (#1979). */
97
+ export type AddonIceDeclaration = Pick<AddonManifest, 'shims' | 'receivers'>;
80
98
  /** Sets one add-on's per-person switch; core binds the slug when it builds the host (#1963). */
81
99
  export type PersonSwitchSetter = (ctx: ActorContext, on: boolean) => Promise<void>;
82
100
  /**
@@ -94,6 +112,9 @@ export declare function addonDeclaredAuditEvents(addonDefaults: Record<string, u
94
112
  * @param setEnabledForPerson - This add-on's switch setter, slug already bound
95
113
  * (`AddonsManager` supplies it); without one, the host's setter refuses
96
114
  * @param engine - The running engine, which names the system principal for
97
- * `systemContext`; without one (a test) it is core's fallback principal
115
+ * `systemContext` and holds the import engine `registerShim` reaches;
116
+ * without one (a test) it is core's fallback principal, and no registry
117
+ * @param manifest - The add-on's manifest (`ngdpbase` in its package.json):
118
+ * the shims and receivers it may register (#1979)
98
119
  */
99
- export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof systemContext>[0]): AddonHost;
120
+ export declare function buildAddonHost(slug: string, addonDefaults: Record<string, unknown>, setEnabledForPerson?: PersonSwitchSetter, engine?: Parameters<typeof systemContext>[0], manifest?: AddonIceDeclaration | null): AddonHost;
@@ -15,6 +15,8 @@
15
15
  * the add-on (#1973);
16
16
  * - `setEnabledForPerson` sets this add-on's own per-person switch (#1963),
17
17
  * bound to its slug, so it can never set another add-on's;
18
+ * - `registerShim` and `registerReceiver` (#1979, ICE) register into the import
19
+ * engine under this add-on's slug, and only the names its manifest declares;
18
20
  * - the egress tools (`resolveEgressPolicy`, `validateUrl`,
19
21
  * `createGuardedElasticsearchClient`, `refusedNodeMessage`) and `isAtLeast`
20
22
  * are core's own pure functions, handed on unchanged, so an add-on never
@@ -64,10 +66,29 @@ function refuseUndeclared(slug, name, declared) {
64
66
  * @param setEnabledForPerson - This add-on's switch setter, slug already bound
65
67
  * (`AddonsManager` supplies it); without one, the host's setter refuses
66
68
  * @param engine - The running engine, which names the system principal for
67
- * `systemContext`; without one (a test) it is core's fallback principal
69
+ * `systemContext` and holds the import engine `registerShim` reaches;
70
+ * without one (a test) it is core's fallback principal, and no registry
71
+ * @param manifest - The add-on's manifest (`ngdpbase` in its package.json):
72
+ * the shims and receivers it may register (#1979)
68
73
  */
69
- export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPersonSwitch, engine) {
74
+ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPersonSwitch, engine, manifest) {
70
75
  const declared = addonDeclaredAuditEvents(addonDefaults);
76
+ const names = (list) => new Set(Array.isArray(list) ? list.filter((n) => typeof n === 'string') : []);
77
+ const iceDeclared = { shim: names(manifest?.shims), receiver: names(manifest?.receivers) };
78
+ const iceRegistry = (what, name) => {
79
+ const allowed = iceDeclared[what];
80
+ if (typeof name !== 'string' || !allowed.has(name)) {
81
+ const message = `Add-on '${slug}' may register only the ${what}s its manifest declares ("ngdpbase": { "${what}s": [...] } in package.json); ` +
82
+ `'${String(name)}' is not one of them (declared: ${allowed.size > 0 ? [...allowed].sort().join(', ') : 'none'}).`;
83
+ logger.error(`[addons] ${message}`);
84
+ throw new Error(message);
85
+ }
86
+ const registry = engine?.getManager('ImportManager');
87
+ if (typeof registry?.registerShim !== 'function' || typeof registry.registerReceiver !== 'function') {
88
+ throw new Error(`register${what === 'shim' ? 'Shim' : 'Receiver'} is not available: this host was built without a site's ImportManager`);
89
+ }
90
+ return registry;
91
+ };
71
92
  const hostAddonAuditEventName = (name) => {
72
93
  const checked = addonAuditEventName(name);
73
94
  refuseUndeclared(slug, name, declared);
@@ -90,6 +111,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
90
111
  logger: logger.child({ addon: slug }),
91
112
  guardedFetch,
92
113
  setEnabledForPerson,
114
+ registerShim: (shim) => iceRegistry('shim', shim?.name).registerShim(slug, shim),
115
+ registerReceiver: (receiver) => iceRegistry('receiver', receiver?.name).registerReceiver(slug, receiver),
93
116
  resolveEgressPolicy,
94
117
  validateUrl,
95
118
  createGuardedElasticsearchClient,
@@ -2,8 +2,8 @@
2
2
  * frontmatterAccess — the single implementation of ACL Tier 1 (#1054).
3
3
  *
4
4
  * A page's frontmatter can restrict who may act on it, via `access[<action>]`
5
- * or, for viewing, `audience`. Two subsystems needed that answer and each had
6
- * its own version of it, which is how they came to disagree:
5
+ * or `audience`, which covers every action (#1964). Two subsystems needed
6
+ * that answer and each had its own version of it, which is how they came to disagree:
7
7
  *
8
8
  * - `PolicyInformationPoint.checkFrontmatterAccess` decided a request, correctly.
9
9
  * - `VersioningFileProvider.getRecentChanges` decided a listing, and only
@@ -22,7 +22,7 @@
22
22
  export interface AccessMetadataLike {
23
23
  /** `{ view: ['role'], edit: [...] }` — per-action principal lists. */
24
24
  access?: unknown;
25
- /** Shorthand for `access.view`. */
25
+ /** Who may act on the page at all; administrators excepted (#1964). */
26
26
  audience?: unknown;
27
27
  }
28
28
  export interface FrontmatterDecision {
@@ -34,21 +34,64 @@ export interface FrontmatterDecision {
34
34
  matched?: string;
35
35
  }
36
36
  /**
37
- * Principals permitted to perform `action`, or null when the page states no
38
- * rule and the caller should fall through to global policy.
39
- *
40
- * `access[action]` wins; `audience` is the shorthand and applies to `view`
41
- * only. An empty list counts as no rule — a page saying `audience: []`
42
- * restricts nobody, which matters because a bare `access:` key parses that way.
37
+ * The page's `audience`, or null when it names none (#1964, operator
38
+ * 2026-10-23). An audience restricts EVERY action: someone outside it may
39
+ * neither read nor edit the page.
40
+ */
41
+ export declare function audiencePrincipals(metadata: AccessMetadataLike | null | undefined): string[] | null;
42
+ /**
43
+ * The page's `access[action]` list, or null when it states none. It applies
44
+ * to that action only, and to everyone — administrators included.
45
+ */
46
+ export declare function accessPrincipals(metadata: AccessMetadataLike | null | undefined, action: string): string[] | null;
47
+ /**
48
+ * The entry `addon:<slug>` (#1964): "people who turned on the per-person
49
+ * add-on <slug>". It is matched only against the viewer's enabled add-ons,
50
+ * never against a role or a username.
51
+ */
52
+ export declare const ADDON_PRINCIPAL_PREFIX = "addon:";
53
+ /**
54
+ * Whether deciding `action` on this page needs the viewer's enabled add-ons —
55
+ * so a caller asks for them only when the page names one (#1964).
43
56
  */
44
- export declare function resolveFrontmatterPrincipals(metadata: AccessMetadataLike | null | undefined, action: string): string[] | null;
57
+ export declare function namesAddonPrincipal(metadata: AccessMetadataLike | null | undefined, action: string): boolean;
58
+ /**
59
+ * Every add-on slug the page names in `audience` or in any `access` list, once
60
+ * each, in the order first named — for the warning about a slug that can match
61
+ * nobody (#1964).
62
+ */
63
+ export declare function addonAudienceSlugs(metadata: AccessMetadataLike | null | undefined): string[];
64
+ /**
65
+ * Whether the page's audience leaves this viewer out — true only when it
66
+ * names one and nothing in it matches. The caller then asks whether the
67
+ * viewer is an administrator, the one exception (#1964), and passes the
68
+ * answer to {@link decideFrontmatterAccess}; it asks only when this is true.
69
+ */
70
+ export declare function audienceExcludes(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], enabledAddons?: readonly string[]): boolean;
71
+ /** The `matched` an administrator outside the audience is admitted by. */
72
+ export declare const AUDIENCE_ADMIN_MATCH = "admin-system";
45
73
  /**
46
74
  * Decide Tier 1 for a viewer described as a flat principal list — their roles
47
75
  * plus their username. Callers holding a `UserContext` should pass
48
76
  * `[...roles, username]`; the provider layer already works in these terms.
49
77
  *
78
+ * Two rules, in order (#1964, operator 2026-10-23):
79
+ *
80
+ * - __`audience`__ applies to every action. Someone it leaves out may neither
81
+ * read nor edit the page: every action is refused. The one exception is an
82
+ * administrator — a viewer holding `admin-system`, which the caller decides
83
+ * and passes as `isAdmin` (this module never reads a role for it). For
84
+ * `view`, a viewer the audience admits is allowed; for any other action it
85
+ * only lets them through to `access` and then policy.
86
+ * - __`access[action]`__ applies to that action, to everyone, administrators
87
+ * included. When the page states it, it decides.
88
+ *
89
+ * An `addon:<slug>` entry matches only when `<slug>` is in `enabledAddons`,
90
+ * the viewer's enabled per-person add-ons (#1964); a caller that does not
91
+ * supply them matches no such entry, so it can under-list but never leak.
92
+ *
50
93
  * Denies when the page states a rule and nothing matches. That default is the
51
94
  * point: a listing that treats "no match" as "show it" is the bug this exists
52
95
  * to prevent.
53
96
  */
54
- export declare function decideFrontmatterAccess(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], action: string): FrontmatterDecision;
97
+ export declare function decideFrontmatterAccess(metadata: AccessMetadataLike | null | undefined, viewerPrincipals: readonly string[], action: string, enabledAddons?: readonly string[], isAdmin?: boolean): FrontmatterDecision;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-run summary persistence for NCM / import conversion outcomes (#738).
3
+ *
4
+ * Writes one JSON file per import run under FAST_STORAGE (configurable via
5
+ * `ngdpbase.import.runs-dir`, default `./data/import-runs`). Each file's
6
+ * name is the ISO timestamp of `startedAt`, sanitised for filesystem use.
7
+ *
8
+ * The trend view (admin-import.ejs) reads the last N summaries via
9
+ * listRecentRuns() to surface dominant failure causes across runs.
10
+ *
11
+ * Append-only by design. Retention is operator-managed for now — the
12
+ * directory can grow indefinitely; a future cleanup job (or admin button)
13
+ * can prune. Listed as a follow-up in the #738 acceptance criteria.
14
+ */
15
+ /** Compact per-run record persisted to disk. */
16
+ export interface ImportRunSummary {
17
+ /** Stable id; same as the filename without `.json`. */
18
+ runId: string;
19
+ /** ISO-8601 timestamp of when the run started. */
20
+ startedAt: string;
21
+ /** ISO-8601 timestamp of when the run finished (may equal startedAt for trivial runs). */
22
+ finishedAt: string;
23
+ /** What kind of import surfaced this run (`paste`, `url`, `file`, `ingest:<sourceId>`). */
24
+ importType: string;
25
+ /**
26
+ * Who initiated the run — `ImportOptions.actorContext.username` (#1236): the
27
+ * request's subject for a manual import, the system principal for a job.
28
+ */
29
+ actor: string;
30
+ /**
31
+ * True when the run was a job with no person behind it (a JobContext whose
32
+ * origin is boot, schedule or operator) — lets the trend view distinguish
33
+ * "alice triggered an import" from "scheduled feed ingested overnight".
34
+ * False for a request, or a request-origin job.
35
+ */
36
+ isSystem?: boolean;
37
+ /** Where the run came from: `request`, `boot`, `schedule` or `operator` (#1236). Absent on records written before it. */
38
+ origin?: string;
39
+ /** Total items the run attempted. */
40
+ total: number;
41
+ /** Items successfully converted to pages. */
42
+ converted: number;
43
+ /** Items intentionally skipped (e.g. duplicates). */
44
+ skipped: number;
45
+ /** Items that failed to convert. */
46
+ failed: number;
47
+ /**
48
+ * Counts of ConversionWarning.kind values aggregated across all items in the run.
49
+ * Keys are flat (no colon-subkinds today) per the #738 design decision.
50
+ */
51
+ kindCounts: Record<string, number>;
52
+ }
53
+ /** Aggregate a flat list of ConversionWarning-like objects into kindCounts. */
54
+ export declare function tallyWarnings(warnings: Array<{
55
+ kind: string;
56
+ }> | undefined): Record<string, number>;
57
+ /** Write a single per-run summary. Idempotent for same runId. Best-effort — errors logged, never thrown. */
58
+ export declare function writeRunSummary(runsDir: string, summary: ImportRunSummary): Promise<void>;
59
+ /**
60
+ * Return the most-recent N run summaries, newest first. Filenames are ISO
61
+ * timestamps so lexicographic sort is chronological. Missing/empty dir
62
+ * returns []. Malformed individual files are skipped, not fatal.
63
+ */
64
+ export declare function listRecentRuns(runsDir: string, limit?: number): Promise<ImportRunSummary[]>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * NCM conversion → /admin/notifications (#728 Phase 2 Slice 5d)
3
+ *
4
+ * Operator decision 2026-05-17: on **non-preview** NCM conversion paths
5
+ * (bulk import, #685 ingestion, MCP) — where no human sees the preview — a
6
+ * summary of the conversion warnings is pushed to the existing
7
+ * `/admin/notifications` centre via `NotificationManager` (the same
8
+ * subsystem `MediaManager` already feeds), so admins get an actionable
9
+ * per-event alert. Distinct from #738 (aggregate metrics/trend).
10
+ *
11
+ * Engine-coupled glue lives here (not in `src/converters/ncm/`, which is
12
+ * kept pure). Best-effort and non-blocking — notification failure must never
13
+ * fail a conversion.
14
+ *
15
+ * @module utils/ncmNotify
16
+ */
17
+ interface EngineLike {
18
+ getManager(name: string): unknown;
19
+ }
20
+ /**
21
+ * Push an admin notification summarising NCM conversion warnings.
22
+ * No-op when there are no warnings or NotificationManager is unavailable.
23
+ *
24
+ * @param engine Wiki engine (structural — just needs getManager)
25
+ * @param source Human label for the path, e.g. "Import jspwiki", "MCP create_page"
26
+ * @param target The page/file that was converted
27
+ * @param warnings Flattened `"kind: detail"` strings
28
+ */
29
+ export declare function notifyNcmConversion(engine: EngineLike, source: string, target: string, warnings: string[]): void;
30
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Reading a takeout handed back for import (#1472) — the mirror of
3
+ * `privateStoreExport.ts`.
4
+ *
5
+ * Pure: entries in, a description of what they hold out. No I/O, no access
6
+ * decision, no write. The door that acts on it is
7
+ * `ImportManager.importOwnStoreTakeout`.
8
+ *
9
+ * What a takeout is, as #1387 writes it: one `{store}/` folder holding each
10
+ * page as `{title}.md` with full frontmatter, files under `attachments/`, and
11
+ * `files-index.json` mapping each file's id to its path in the archive. The
12
+ * reader is lenient about the one thing people change — the folder. Someone
13
+ * who extracts a takeout and zips it again may add a level, or zip the
14
+ * contents with no folder at all, so the takeout's root is found rather than
15
+ * assumed.
16
+ */
17
+ import type { ZipEntry } from './zipArchive.js';
18
+ /** One page of a takeout. */
19
+ export type TakeoutPage = {
20
+ /** Where it was in the archive — how the report names a page that failed. */
21
+ archivePath: string;
22
+ /** From the frontmatter, else the file's name. */
23
+ title: string;
24
+ /** From the frontmatter. Absent for a page written by hand. */
25
+ uuid?: string;
26
+ /** The frontmatter, as written. */
27
+ metadata: Record<string, unknown>;
28
+ body: string;
29
+ };
30
+ /** One file of a takeout. */
31
+ export type TakeoutFile = {
32
+ archivePath: string;
33
+ /** Its id where it came from, when the file index names it. */
34
+ oldId?: string;
35
+ /** The name it was uploaded with. */
36
+ name: string;
37
+ encodingFormat?: string;
38
+ description?: string;
39
+ bytes: Buffer;
40
+ };
41
+ export type ParsedTakeout = {
42
+ pages: TakeoutPage[];
43
+ files: TakeoutFile[];
44
+ /** Archive members that are neither a page nor a file, left alone. */
45
+ ignored: string[];
46
+ };
47
+ /** What a takeout's entries hold. */
48
+ export declare function readTakeout(entries: readonly ZipEntry[]): ParsedTakeout;
49
+ /** `body` with every `/attachments/{old}` link pointed at its new id. */
50
+ export declare function rewriteAttachmentLinks(body: string, ids: ReadonlyMap<string, string>): string;
51
+ /** Whether `body` links to the file `id`. */
52
+ export declare function linksToAttachment(body: string, id: string): boolean;
53
+ /**
54
+ * The first free title of `title`, `title (imported)`, `title (imported 2)` …
55
+ * (operator, 2026-09-25): a DIFFERENT page already holding the title keeps it,
56
+ * and the import lands beside it.
57
+ */
58
+ export declare function freeImportTitle(title: string, isTaken: (candidate: string) => Promise<boolean>): Promise<string>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Writing a zip archive from bytes already in hand (#1387).
3
+ *
4
+ * A takeout lands on the owner's laptop or phone, and that decides the format:
5
+ * iOS, iPadOS, Android, Windows and macOS all open a `.zip` by tapping it,
6
+ * while `.tar.gz` needs a third-party app on exactly the devices people carry.
7
+ *
8
+ * This is the only archive the system writes, and `readZip` below is the only
9
+ * one it reads: a takeout handed back for import (#1472). The instance backup does not use
10
+ * one: it carries private files as base64 inside the backup document (#1387),
11
+ * so there is nothing to unpack on restore.
12
+ *
13
+ * Bytes in, one Buffer out: a takeout is decrypted, and nothing decrypted may
14
+ * be staged on the server's disk, so there is no file for a packer to read.
15
+ *
16
+ * Written here rather than taken from a library because the zip container is
17
+ * small and completely specified, while every candidate library wants to read
18
+ * from or write to the filesystem. The tests extract each archive with an
19
+ * independent implementation and compare the bytes, so this is checked against
20
+ * a real reader rather than against its own assumptions.
21
+ *
22
+ * Deliberately NOT implemented: zip64. An archive over 4 GiB, a member over
23
+ * 4 GiB, or more than 65,535 members is refused with a clear error rather than
24
+ * written as something an extractor would mis-read. A store that large wants
25
+ * streaming and zip64 together, which is its own piece of work.
26
+ */
27
+ /** One file to put in the archive. */
28
+ export type ZipEntry = {
29
+ /** Path inside the archive, `/`-separated, no leading slash. */
30
+ path: string;
31
+ bytes: Buffer;
32
+ /** Defaults to now. Zip stores 2-second resolution, and nothing before 1980. */
33
+ mtime?: Date;
34
+ };
35
+ export declare function crc32(buf: Buffer): number;
36
+ /**
37
+ * A zip archive of the given entries, as one Buffer.
38
+ *
39
+ * Entries are deflated when that makes them smaller — markdown compresses well,
40
+ * and a phone on mobile data is the expected destination — and stored as-is
41
+ * when it does not, which is the usual outcome for photos and other media.
42
+ */
43
+ export declare function packZip(entries: readonly ZipEntry[]): Promise<Buffer>;
44
+ /** Limits on what `readZip` will unpack, so an upload cannot exhaust memory. */
45
+ export type ZipReadLimits = {
46
+ /** Most members accepted. */
47
+ maxEntries: number;
48
+ /** Most bytes accepted once everything is inflated. */
49
+ maxTotalBytes: number;
50
+ };
51
+ /**
52
+ * The files in a zip archive, as bytes in memory (#1472).
53
+ *
54
+ * Read from the central directory, which is the archive's own table of
55
+ * contents, so a member written with a data descriptor — what a streaming
56
+ * writer, or an OS re-zipping a folder, produces — is read the same as any
57
+ * other. Directory entries are dropped, and so is what macOS adds when it
58
+ * zips a folder (`__MACOSX/`, `.DS_Store`): they are not anyone's data.
59
+ *
60
+ * Refused, with an error naming why, rather than read as something else:
61
+ *
62
+ * - a path that would climb out of wherever it is extracted — absolute, or
63
+ * holding a `..` segment, with `\` read as the separator it is on Windows;
64
+ * - encryption, zip64, split archives, and any method but stored and deflate;
65
+ * - a member whose inflated bytes do not match the size or CRC the archive
66
+ * declares for it;
67
+ * - more members, or more inflated bytes, than `limits` allow. Inflating is
68
+ * capped per member at what is left of the budget, so a small archive
69
+ * that expands enormously stops at the budget, not at the machine's memory.
70
+ */
71
+ export declare function readZip(archive: Buffer, limits: ZipReadLimits): ZipEntry[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.26.0",
3
+ "version": "5.27.0",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",