@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
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Import Manager
3
+ *
4
+ * Manages the import of content from external wiki formats into ngdpbase.
5
+ * Uses an extensible converter registry pattern to support multiple formats
6
+ * (JSPWiki, MediaWiki, Confluence, etc.).
7
+ *
8
+ * @module ImportManager
9
+ *
10
+ * @example
11
+ * const importManager = engine.getManager('ImportManager');
12
+ *
13
+ * // Register additional converters
14
+ * importManager.registerConverter(new MediaWikiConverter());
15
+ *
16
+ * // Preview import
17
+ * const preview = await importManager.previewImport({
18
+ * sourceDir: '/path/to/wiki',
19
+ * format: 'auto'
20
+ * });
21
+ *
22
+ * // Execute import
23
+ * const result = await importManager.importPages({
24
+ * sourceDir: '/path/to/wiki',
25
+ * format: 'jspwiki',
26
+ * dryRun: false
27
+ * });
28
+ */
29
+ import { type ActorContext } from '../context/ActorContext.js';
30
+ import BaseManager, { BackupData } from './BaseManager.js';
31
+ import type { WikiEngine } from '../types/WikiEngine.js';
32
+ import { IContentConverter } from '../converters/IContentConverter.js';
33
+ import { type ZipReadLimits } from '../utils/zipArchive.js';
34
+ import { type JobContext } from '../context/JobContext.js';
35
+ import type { IceLedger, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.js';
36
+ /**
37
+ * Options for import operations
38
+ */
39
+ export interface ImportOptions {
40
+ /** Source directory containing files to import */
41
+ sourceDir: string;
42
+ /**
43
+ * Target directory for converted files (default: the live pages dir).
44
+ * When this resolves to the live pages directory, imports go through
45
+ * PageManager save semantics (#880 — indexed, versioned, in Recent
46
+ * Changes). Any other directory means convert-to-files/export mode:
47
+ * raw markdown files are written there instead.
48
+ */
49
+ targetDir?: string;
50
+ /** Format ID or 'auto' for auto-detection */
51
+ format?: string;
52
+ /** Keep original files after conversion (default: true) */
53
+ preserveOriginals?: boolean;
54
+ /** Preview only, don't write files (default: false) */
55
+ dryRun?: boolean;
56
+ /** Generate UUIDs for pages that don't have them (default: true) */
57
+ generateUUIDs?: boolean;
58
+ /** File extensions to process (default: determined by format) */
59
+ fileExtensions?: string[];
60
+ /** Maximum files to process (for large imports) */
61
+ limit?: number;
62
+ /** Skip first N files (for resuming imports) */
63
+ offset?: number;
64
+ /** Progress callback for streaming updates */
65
+ onProgress?: (event: ImportProgressEvent) => void;
66
+ /**
67
+ * Categorises this run for the per-run summary (#738). Examples:
68
+ * `'paste'`, `'url'`, `'file'`, `'jspwiki'`, `'ingest:<sourceId>'`.
69
+ * Defaults to `'import'` when caller doesn't specify.
70
+ */
71
+ importType?: string;
72
+ /**
73
+ * The identity that initiated this run, forwarded rather than flattened
74
+ * (#1164, #1179, #1236).
75
+ *
76
+ * Until #1236 this sat beside `actor?: string` and `actorIsSystem?`, two
77
+ * more carriers of the same fact that the run summary and the page
78
+ * frontmatter read instead. One identity now: the run's actor, the page's
79
+ * `editor` and its `author` fallback, and the summary's `isSystem` are all
80
+ * derived from this context. A username string cannot carry a
81
+ * delegation. `importPageAttachments` needed a context to authorise an
82
+ * upload, could not get one from a string, and __fabricated__ one:
83
+ *
84
+ * context: { username: author, name: author, isAuthenticated: true,
85
+ * roles: ['admin'] }
86
+ *
87
+ * where `author` came from the imported file's own `attachment.properties`.
88
+ * The PDP (then `UserManager.hasPermission`) trusts supplied roles verbatim — it does not
89
+ * re-resolve them — so that object authorised as an admin, attributed the
90
+ * write to a name taken from imported content, and carried no `viaToken`,
91
+ * so an admin's agent token doing an import escaped its scope ceiling.
92
+ *
93
+ * That is P1 twice over: a parameter that could not carry provenance, and a
94
+ * synthetic admin principal invented to fill the gap it left.
95
+ *
96
+ * Mandatory: the upload door records it, the summary names it, the page
97
+ * carries it. A non-request caller passes a JobContext with its reason.
98
+ */
99
+ actorContext: ActorContext;
100
+ /**
101
+ * What to do when a file's title matches an existing page (#874).
102
+ * `'skip'` (default) — leave the existing page untouched, report duplicate.
103
+ * `'overwrite'` — update the existing page in place through PageManager save
104
+ * semantics: UUID, author, created, and slug preserved; version bumped by
105
+ * the provider; `editor` = `actor`; search index and link graph updated
106
+ * in-band (same contract as an edit-form save / the ingest API upsert).
107
+ * Folder import only — URL import keeps hard-skip.
108
+ */
109
+ conflictPolicy?: 'skip' | 'overwrite';
110
+ /**
111
+ * Explicit private-store destination (#1389). Markdown still goes through
112
+ * PageManager. JSON/XML become files in the store via AttachmentManager.
113
+ * Never inferred from a page. Store id from ConfigurationManager when omitted.
114
+ */
115
+ private?: boolean;
116
+ store?: string;
117
+ }
118
+ /**
119
+ * Progress event for streaming import updates
120
+ */
121
+ export interface ImportProgressEvent {
122
+ /** Event type */
123
+ type: 'start' | 'progress' | 'complete' | 'error';
124
+ /** Source file path */
125
+ file?: string;
126
+ /** Current file index (0-based) */
127
+ index?: number;
128
+ /** Total files to process */
129
+ total?: number;
130
+ /** Status of the file import */
131
+ status?: 'success' | 'skipped' | 'failed';
132
+ /** Error message if failed */
133
+ error?: string;
134
+ /** Imported page title (success only) — lets the UI link to /view/<title> */
135
+ pageTitle?: string;
136
+ /** Final result (for complete event) */
137
+ result?: unknown;
138
+ }
139
+ /**
140
+ * Error information for failed imports
141
+ */
142
+ export interface ImportError {
143
+ /** Source file path */
144
+ file: string;
145
+ /** Error message */
146
+ message: string;
147
+ /** Error stack trace (optional) */
148
+ stack?: string;
149
+ }
150
+ /**
151
+ * Information about an imported file
152
+ */
153
+ export interface ImportedFile {
154
+ /** Original source file path */
155
+ sourcePath: string;
156
+ /** Target file path */
157
+ targetPath: string;
158
+ /** Format detected/used */
159
+ format: string;
160
+ /** File size in bytes */
161
+ size: number;
162
+ /** Extracted metadata */
163
+ metadata: Record<string, unknown>;
164
+ /** Conversion warnings */
165
+ warnings: string[];
166
+ /** Whether file was actually written (false for dry runs or duplicates) */
167
+ written: boolean;
168
+ /** Reason the file was skipped (e.g. 'duplicate') */
169
+ skippedReason?: string;
170
+ /** UUID of existing page if duplicate */
171
+ existingPageUuid?: string;
172
+ /** True when an existing page was (or on dry run: would be) updated in place (#874) */
173
+ overwritten?: boolean;
174
+ /** Attachment import stats (JSPWiki imports only) */
175
+ attachments?: {
176
+ imported: number;
177
+ skipped: number;
178
+ errors: string[];
179
+ };
180
+ }
181
+ /**
182
+ * Result of an import operation
183
+ */
184
+ export interface ImportResult {
185
+ /** Overall success status */
186
+ success: boolean;
187
+ /** Number of files converted */
188
+ converted: number;
189
+ /** Number of files skipped */
190
+ skipped: number;
191
+ /** Number of files with errors */
192
+ failed: number;
193
+ /** Total files processed */
194
+ total: number;
195
+ /** Error details */
196
+ errors: ImportError[];
197
+ /** Information about imported files */
198
+ files: ImportedFile[];
199
+ /** Import duration in milliseconds */
200
+ durationMs: number;
201
+ }
202
+ /**
203
+ * Why a takeout import was refused before anything was written (#1472).
204
+ * A refusal is never a partial import.
205
+ */
206
+ export declare class TakeoutImportRefused extends Error {
207
+ readonly reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable';
208
+ constructor(reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable', message: string);
209
+ }
210
+ /** One page's outcome in a takeout import (#1472). */
211
+ export type TakeoutPageOutcome = {
212
+ title: string;
213
+ outcome: 'imported';
214
+ importedAs: string;
215
+ } | {
216
+ title: string;
217
+ outcome: 'unchanged';
218
+ } | {
219
+ title: string;
220
+ outcome: 'changed-since-takeout';
221
+ }
222
+ /** `where` is named only when the requester may view that page. */
223
+ | {
224
+ title: string;
225
+ outcome: 'uuid-elsewhere';
226
+ where?: string;
227
+ } | {
228
+ title: string;
229
+ outcome: 'failed';
230
+ message: string;
231
+ };
232
+ export type TakeoutImportReport = {
233
+ store: string;
234
+ pages: TakeoutPageOutcome[];
235
+ /** Files stored, or matched to one the store already held. */
236
+ files: number;
237
+ /** Files that could not be stored, by name. */
238
+ fileErrors: Array<{
239
+ name: string;
240
+ message: string;
241
+ }>;
242
+ /**
243
+ * Files stored that no page now in the store links to — typically because
244
+ * the pages that used them were skipped (operator, 2026-09-25: they come
245
+ * in, and are reported). Named here, for the owner; counted in the logs.
246
+ */
247
+ unlinkedFiles: string[];
248
+ /** Archive members that are neither a page nor a file. */
249
+ ignored: string[];
250
+ /**
251
+ * The private page this import's report was saved as, in the target store
252
+ * (operator, 2026-09-25). Absent only when that save failed.
253
+ */
254
+ reportPage?: string;
255
+ };
256
+ /**
257
+ * Import Manager class
258
+ *
259
+ * Manages content import with extensible format support via converter registry.
260
+ */
261
+ declare class ImportManager extends BaseManager {
262
+ /** Registry of format converters */
263
+ private converterRegistry;
264
+ /** ICE (#1979): shims by id (`<owner>.<name>`), in registration order. */
265
+ private readonly shimRegistry;
266
+ /** ICE: receivers by id. */
267
+ private readonly receiverRegistry;
268
+ /** ICE: runs this process knows about, oldest first. */
269
+ private readonly iceRuns;
270
+ /** ICE: runs waiting for the background job, each with its own context. */
271
+ private readonly iceQueue;
272
+ /** ICE: true while a background job is draining the queue. */
273
+ private iceDraining;
274
+ /** ICE: the last background run id the queue was handed to. */
275
+ private iceLastJobRunId;
276
+ private iceLedger;
277
+ constructor(engine: WikiEngine);
278
+ /**
279
+ * Initialize the manager
280
+ */
281
+ initialize(config?: Record<string, unknown>): Promise<void>;
282
+ /**
283
+ * Register a content converter. Since #1979 (D8) it is also the ICE shim
284
+ * `core.<formatId>`, producing NCM for the pages receiver, so the folder
285
+ * import and the engine run the same conversion.
286
+ *
287
+ * @param converter - Converter instance implementing IContentConverter
288
+ */
289
+ registerConverter(converter: IContentConverter): void;
290
+ /**
291
+ * Register a shim (D6, D7). Core registers its own as owner `core`; an
292
+ * add-on registers through `host.registerShim`, which names the add-on as
293
+ * the owner and refuses a shim its manifest does not declare.
294
+ *
295
+ * @returns The shim's id, `<owner>.<name>`
296
+ * @throws Error when the shim is malformed or its id is taken
297
+ */
298
+ registerShim(owner: string, shim: IceShim): string;
299
+ /**
300
+ * Register a receiver (D4): a door that already exists. Core's are
301
+ * `core.pages` and `core.files`; an add-on registers its own manager's door
302
+ * through `host.registerReceiver`.
303
+ *
304
+ * @returns The receiver's id, `<owner>.<name>`
305
+ */
306
+ registerReceiver(owner: string, receiver: IceReceiver): string;
307
+ /** Is this shim switched on (D6)? `ngdpbase.import.shims.<id>.enabled`, default on. */
308
+ isShimEnabled(id: string): boolean;
309
+ /** Every registered shim, and whether it is on. */
310
+ listShims(): Array<Pick<RegisteredShim, 'id' | 'owner' | 'label' | 'direction' | 'produces' | 'receivers'> & {
311
+ enabled: boolean;
312
+ }>;
313
+ /** The receivers this context may use (D5): each answers with its own existing permission. */
314
+ receiversFor(ctx: ActorContext): Promise<Array<{
315
+ id: string;
316
+ label?: string;
317
+ }>>;
318
+ /**
319
+ * Run one import now and wait for it. For code already off the request
320
+ * path: an add-on's own background job, a test. A route uses
321
+ * {@link enqueueImport}, so the work runs off the request path.
322
+ *
323
+ * @param ctx - Who it runs as: the person's context, or a job's (P1)
324
+ */
325
+ runImport(request: IceRunRequest, ctx: ActorContext): Promise<IceRunReport>;
326
+ /**
327
+ * Queue an import as background work and return its run id at once. The
328
+ * run carries `ctx` — the person who asked (`jobContextFromRequest`), or a
329
+ * job's context with its reason — and each receiver checks its own
330
+ * permission with it when the run reaches it (D5).
331
+ */
332
+ enqueueImport(request: IceRunRequest, ctx: JobContext): Promise<string>;
333
+ /** A run's report, to the person it runs as; null to anyone else. */
334
+ getImportRun(runId: string, ctx: ActorContext): IceRunReport | null;
335
+ /** Use this ledger instead of the file one: a test's, or a database table's. */
336
+ setImportLedger(ledger: IceLedger): void;
337
+ private newRunReport;
338
+ private executeRun;
339
+ private enabledShims;
340
+ /**
341
+ * Hand the queue to the background job. One job run drains every queued
342
+ * import, each under its own context. A job run that has finished draining
343
+ * but is not yet marked done answers `enqueue` with its old run id; then
344
+ * try again shortly, so nothing queued is stranded.
345
+ */
346
+ private kickIceQueue;
347
+ private setting;
348
+ private numberSetting;
349
+ /** `ngdpbase.import.engine.*`: every limit the engine applies is configuration. */
350
+ private engineSettings;
351
+ /** The engine's ledger (fingerprints, per-file status, item keys), opened on first use. */
352
+ private ledger;
353
+ /** What a shim is handed: the engine's guarded fetch (design rule) and its own options (D6). */
354
+ private shimTools;
355
+ /** A receiver's existing permission, asked of the PDP with the run's context (D5, P2). */
356
+ private permits;
357
+ /**
358
+ * `core.pages` (D4): NCM into pages, through PageManager's save door, as the
359
+ * folder import saves. It checks `page-create` itself (D5). A title that
360
+ * already exists is a duplicate, as it is for the folder import by default.
361
+ */
362
+ private pagesReceiver;
363
+ /**
364
+ * `core.files` (D4): a file through AttachmentManager's upload door, which
365
+ * checks `asset-upload` itself. Also where a kept original goes (D11) when
366
+ * the chosen receiver does not keep its own. It becomes FileManager's write
367
+ * door once the files layer has one (#1851).
368
+ */
369
+ private filesReceiver;
370
+ /**
371
+ * Get available format IDs
372
+ *
373
+ * @returns Array of registered format identifiers
374
+ */
375
+ getAvailableFormats(): string[];
376
+ /**
377
+ * Get converter by format ID
378
+ *
379
+ * @param formatId - Format identifier
380
+ * @returns Converter instance or undefined
381
+ */
382
+ getConverter(formatId: string): IContentConverter | undefined;
383
+ /**
384
+ * Get all registered converters with their metadata
385
+ *
386
+ * @returns Array of converter info objects
387
+ */
388
+ getConverterInfo(): Array<{
389
+ formatId: string;
390
+ formatName: string;
391
+ fileExtensions: string[];
392
+ }>;
393
+ /**
394
+ * Auto-detect format from file content
395
+ *
396
+ * @param content - File content
397
+ * @param filename - Filename (for extension matching)
398
+ * @returns Format ID or null if no match
399
+ */
400
+ detectFormat(content: string, filename: string): string | null;
401
+ /**
402
+ * Preview import without writing files
403
+ *
404
+ * @param options - Import options (dryRun is forced to true)
405
+ * @returns Import result with preview data
406
+ */
407
+ previewImport(options: ImportOptions): Promise<ImportResult>;
408
+ /**
409
+ * Import pages with streaming progress updates
410
+ * Alias for importPages that makes the progress callback more explicit
411
+ *
412
+ * @param options - Import options with onProgress callback
413
+ * @returns Import result with statistics and file details
414
+ */
415
+ importPagesWithProgress(options: ImportOptions): Promise<ImportResult>;
416
+ /**
417
+ * Import pages from source directory
418
+ *
419
+ * @param options - Import options
420
+ * @returns Import result with statistics and file details
421
+ */
422
+ importPages(options: ImportOptions): Promise<ImportResult>;
423
+ /**
424
+ * Import a single page
425
+ *
426
+ * @param filePath - Source file path
427
+ * @param options - Import options
428
+ * @returns Imported file info or null if skipped
429
+ */
430
+ importSinglePage(filePath: string, options: ImportOptions): Promise<ImportedFile | null>;
431
+ /**
432
+ * Update an existing page in place from an import (#874 conflict policy
433
+ * 'overwrite'). Mirrors the edit-form / ingest-API save contract: UUID,
434
+ * author, created, and slug are preserved from the existing page; the
435
+ * provider bumps the version; `editor` is the import actor; the search
436
+ * index, link graph, and render cache are updated in-band so the result
437
+ * is immediately searchable and link-resolvable.
438
+ */
439
+ private overwriteExistingPage;
440
+ /**
441
+ * A title the save door had to rewrite (#1455) is named in this file's
442
+ * warnings, so the import report says what the source called the page and
443
+ * what it is called here. Returns the title the page actually has.
444
+ */
445
+ private reportNormalisedTitle;
446
+ /**
447
+ * Create a new page from an import through PageManager save semantics
448
+ * (#880): the provider assigns versioning and the page comes out indexed,
449
+ * link-resolvable, and visible in Recent Changes — instead of the raw file
450
+ * write that left pages invisible to search until a manual reindex.
451
+ * `author` and `editor` are the import actor (unless the source frontmatter
452
+ * carries an author).
453
+ */
454
+ private createPageThroughPipeline;
455
+ /**
456
+ * Import a page from a URL
457
+ *
458
+ * Fetches the URL and imports it like a file import of the same HTML
459
+ * (#1337): the NCM normalizer (links, tables, `ncmVersion`), the #1332 fix
460
+ * steps, then a save through PageManager — validated, audited as the
461
+ * importer, versioned and indexed. The fetched page's schema.org metadata
462
+ * and the source URL are kept in frontmatter.
463
+ *
464
+ * @param url - URL to fetch and import
465
+ * @param options - The importer's context, and optional title and dryRun
466
+ * @returns Imported file info
467
+ */
468
+ importFromUrl(url: string, options: {
469
+ actorContext: ActorContext;
470
+ title?: string;
471
+ dryRun?: boolean;
472
+ }): Promise<ImportedFile>;
473
+ /**
474
+ * Run every Markdown fix step over an imported body (#1332), through
475
+ * PageManager — the steps Convert to NCM runs. Each step that changed
476
+ * something is added to `warnings` as a `converter-note`, which the import
477
+ * preview and the run notification already show. Without a PageManager the
478
+ * body is returned unchanged.
479
+ *
480
+ * @param body - Page body, without frontmatter
481
+ * @param warnings - The conversion's warnings, appended to
482
+ * @returns The fixed body
483
+ */
484
+ private applyFixSteps;
485
+ /** Style classes for up-converted tables (NCM §2.1), operator-configurable. */
486
+ private ncmTableClasses;
487
+ /**
488
+ * JSON/XML into a private store are files, not pages (#1389).
489
+ */
490
+ private importPrivateStoreFile;
491
+ /**
492
+ * Import attachments from a JSPWiki `-att/` directory alongside a page file.
493
+ *
494
+ * @param sourceFilePath - Path to the source `.txt` page file
495
+ * @param pageName - Decoded page name (used to link attachments)
496
+ * @param options - Import options (dryRun support)
497
+ * @returns Stats about imported attachments
498
+ */
499
+ private importPageAttachments;
500
+ /**
501
+ * Get MIME type from file extension
502
+ */
503
+ private getMimeType;
504
+ /**
505
+ * Get file extensions to process based on options
506
+ */
507
+ private getFileExtensions;
508
+ /**
509
+ * Find all files with matching extensions in directory
510
+ */
511
+ private findFiles;
512
+ /**
513
+ * Build YAML frontmatter from conversion result
514
+ */
515
+ private buildImportMetadata;
516
+ private buildFrontmatter;
517
+ /**
518
+ * Register new user-keywords to the custom configuration
519
+ *
520
+ * When importing JSPWiki pages with %%category%% blocks, this method
521
+ * adds extracted categories to the config so they appear in the
522
+ * user-keywords picker when editing pages.
523
+ *
524
+ * @param keywords - Array of keyword names to register
525
+ * @returns Number of new keywords added
526
+ */
527
+ private registerUserKeywordsToConfig;
528
+ /**
529
+ * Import a takeout into one of the requester's OWN private stores (#1472).
530
+ *
531
+ * The mirror of `PageManager.buildOwnStoreTakeout`, and not `importPages`:
532
+ * that reads a directory on the server, so a decrypted takeout would have to
533
+ * be staged on disk, and it converts every page, which would rewrite bodies
534
+ * that are already this system's own markdown. Here the archive is read in
535
+ * memory and each page and file goes through the same doors an edit and an
536
+ * upload use, so a sealed store encrypts on write with no new crypto.
537
+ *
538
+ * Refused before anything is written — never a partial import — when the
539
+ * requester is not the owner, the store is not theirs, or it is encrypted
540
+ * and locked in this session.
541
+ *
542
+ * Idempotent (operator, 2026-09-25): importing the same takeout twice leaves
543
+ * the store as the first import did.
544
+ *
545
+ * - Files first, so page links can be pointed at them. The store's upload
546
+ * finds a file whose bytes it already holds and returns that one, so a
547
+ * second import stores nothing new.
548
+ * - A page whose uuid the target store already holds is skipped:
549
+ * `unchanged` when its body matches, `changed-since-takeout` when not —
550
+ * the live page wins.
551
+ * - A page whose uuid is used elsewhere on the site is skipped as
552
+ * `uuid-elsewhere`, naming the page only when the requester may view it.
553
+ * - A title held by a DIFFERENT page lands beside it as `Title (imported)`.
554
+ *
555
+ * This is not a restore: a takeout carries no history and no trash.
556
+ *
557
+ * @param ctx - Who is importing; only into their own container
558
+ */
559
+ importOwnStoreTakeout(ctx: ActorContext, options: {
560
+ store: string;
561
+ archive: Buffer;
562
+ limits: ZipReadLimits;
563
+ /** The uploaded file's name, for the report page. */
564
+ sourceName?: string;
565
+ }): Promise<TakeoutImportReport>;
566
+ /** The import report page's markdown. Private links are `[Title|store/Title]` (#1457). */
567
+ private takeoutReportMarkdown;
568
+ /**
569
+ * Where a uuid is already in use outside the target store, or null.
570
+ *
571
+ * The owner's other stores this session can read, then the public pages.
572
+ * `where` is set only for a page the requester may view: a uuid held by a
573
+ * page they cannot see is reported without naming it, so an import cannot
574
+ * be used to probe for pages.
575
+ */
576
+ private whereUuidLives;
577
+ /**
578
+ * Backup manager data (no persistent data to backup)
579
+ */
580
+ backup(): Promise<BackupData>;
581
+ /**
582
+ * Restore manager data (no persistent data to restore)
583
+ */
584
+ restore(backupData: BackupData): Promise<void>;
585
+ /**
586
+ * Shutdown the manager
587
+ */
588
+ shutdown(): Promise<void>;
589
+ }
590
+ export default ImportManager;
@@ -184,6 +184,15 @@ export type PageRead<T> = {
184
184
  export interface PageConvertResult extends NcmResult {
185
185
  fixes: FixChange[];
186
186
  }
187
+ /**
188
+ * A write refused because the page's `audience` leaves the writer out (#1964).
189
+ * Someone outside a page's audience may neither read nor edit it; an
190
+ * administrator (`admin-system`) is the exception. Routes answer it with 403.
191
+ */
192
+ export declare class PageAudienceRefusedError extends Error {
193
+ readonly pageName: string;
194
+ constructor(pageName: string);
195
+ }
187
196
  export declare class PageContentValidationError extends Error {
188
197
  readonly validationErrors: FilterValidationError[];
189
198
  constructor(pageName: string, validationErrors: FilterValidationError[]);
@@ -797,10 +806,22 @@ declare class PageManager extends BaseManager implements CatalogSource {
797
806
  * the normal getPage() path sanitises or normalises away. Returns null
798
807
  * when the page is unknown or the provider doesn't support raw reads.
799
808
  */
800
- getRawPageContent(identifier: string): Promise<{
809
+ getRawPageContent(identifier: string, ctx: ActorContext): Promise<{
801
810
  filePath: string;
802
811
  content: string;
803
812
  } | null>;
813
+ /**
814
+ * The write door's audience check (#1964, operator 2026-10-23): someone a
815
+ * page's `audience` leaves out may neither read nor edit it, by any route —
816
+ * the editor, the raw editor, the API, a rename, a delete. The rule is the
817
+ * PIP's (`audienceAdmits`, the same one Tier 1 applies), asked with the
818
+ * caller's context; an administrator (`admin-system`) is admitted. A page
819
+ * that states no audience is not checked here — the routes ask the full
820
+ * page decision before they call the door.
821
+ *
822
+ * @throws PageAudienceRefusedError when the audience leaves `ctx` out
823
+ */
824
+ private assertAudienceAdmits;
804
825
  /**
805
826
  * Admin-override save (#689): the admin "Edit raw" textarea, split into
806
827
  * frontmatter and body and written through the door (#1462).
@@ -1290,6 +1311,29 @@ declare class PageManager extends BaseManager implements CatalogSource {
1290
1311
  * @param action - Legacy verb (`view`, `edit`) or policy action; default `view`
1291
1312
  */
1292
1313
  filterPagesFor(subject: ActorContext | PermissionSubject | null | undefined, names: readonly string[], action?: string): Promise<string[]>;
1314
+ /**
1315
+ * The `addon:<slug>` entries on a page that can match nobody (#1964): the
1316
+ * slugs it names in `audience` or `access` that are not a loaded per-person
1317
+ * add-on. Such an entry still decides — it refuses everyone it would have
1318
+ * let in — so the author is told.
1319
+ *
1320
+ * @param metadata - The page's frontmatter
1321
+ * @returns The slugs, in the order the page names them
1322
+ */
1323
+ unmatchableAddonAudience(metadata: Partial<PageFrontmatter> | null | undefined): string[];
1324
+ /** Warn, naming the page and each slug, about {@link unmatchableAddonAudience}. */
1325
+ private warnUnmatchableAddonAudience;
1326
+ /**
1327
+ * At startup, once add-ons have loaded (#1964): warn about every page whose
1328
+ * `audience` or `access` names an add-on that can match nobody — unknown,
1329
+ * not per-person, or not loaded.
1330
+ *
1331
+ * @returns Each page and slug warned about
1332
+ */
1333
+ reportUnmatchableAddonAudiences(): Promise<Array<{
1334
+ page: string;
1335
+ slug: string;
1336
+ }>>;
1293
1337
  /**
1294
1338
  * Get all page titles (explicit alias for getAllPages)
1295
1339
  * Prefer this for new code that only needs page names.