@formio/uag 1.13.2 → 2.0.0-rc.10

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 (115) hide show
  1. package/README.md +237 -535
  2. package/bin/uag-eval.js +99 -0
  3. package/bin/uag.js +116 -0
  4. package/lib/UAGFormInterface.d.ts +72 -2
  5. package/lib/UAGFormInterface.js +226 -57
  6. package/lib/UAGProjectInterface.d.ts +45 -5
  7. package/lib/UAGProjectInterface.js +187 -36
  8. package/lib/actionLogs.d.ts +24 -0
  9. package/lib/actionLogs.js +149 -0
  10. package/lib/actions/runTask.d.ts +43 -0
  11. package/lib/actions/runTask.js +324 -0
  12. package/lib/agent/anonymous.d.ts +34 -0
  13. package/lib/agent/anonymous.js +99 -0
  14. package/lib/agent/chat.d.ts +114 -0
  15. package/lib/agent/chat.js +475 -0
  16. package/lib/agent/engines.d.ts +17 -0
  17. package/lib/agent/engines.js +46 -0
  18. package/lib/agent/errors.d.ts +25 -0
  19. package/lib/agent/errors.js +132 -0
  20. package/lib/agent/index.d.ts +4 -0
  21. package/lib/agent/index.js +19 -0
  22. package/lib/agent/remote.d.ts +71 -0
  23. package/lib/agent/remote.js +252 -0
  24. package/lib/agent/requestId.d.ts +4 -0
  25. package/lib/agent/requestId.js +19 -0
  26. package/lib/agent/router.d.ts +61 -0
  27. package/lib/agent/router.js +307 -0
  28. package/lib/agent/stubs.d.ts +37 -0
  29. package/lib/agent/stubs.js +39 -0
  30. package/lib/agent/tools.d.ts +31 -0
  31. package/lib/agent/tools.js +76 -0
  32. package/lib/config.d.ts +97 -3
  33. package/lib/config.js +19 -0
  34. package/lib/database.d.ts +70 -0
  35. package/lib/database.js +212 -0
  36. package/lib/documents/convert.d.ts +38 -0
  37. package/lib/documents/convert.js +151 -0
  38. package/lib/documents/converters.d.ts +28 -0
  39. package/lib/documents/converters.js +149 -0
  40. package/lib/documents/detect.d.ts +31 -0
  41. package/lib/documents/detect.js +169 -0
  42. package/lib/documents/index.d.ts +9 -0
  43. package/lib/documents/index.js +43 -0
  44. package/lib/documents/mime.d.ts +39 -0
  45. package/lib/documents/mime.js +100 -0
  46. package/lib/documents/plugins.d.ts +18 -0
  47. package/lib/documents/plugins.js +65 -0
  48. package/lib/documents/readers.d.ts +19 -0
  49. package/lib/documents/readers.js +64 -0
  50. package/lib/documents/routes.d.ts +52 -0
  51. package/lib/documents/routes.js +99 -0
  52. package/lib/documents/types.d.ts +126 -0
  53. package/lib/documents/types.js +2 -0
  54. package/lib/documents/uagJs.d.ts +17 -0
  55. package/lib/documents/uagJs.js +157 -0
  56. package/lib/formflow.d.ts +47 -0
  57. package/lib/formflow.js +28 -0
  58. package/lib/identity.d.ts +53 -0
  59. package/lib/identity.js +107 -0
  60. package/lib/index.d.ts +98 -1
  61. package/lib/index.js +382 -8
  62. package/lib/markdown.d.ts +1 -0
  63. package/lib/markdown.js +166 -0
  64. package/lib/modules/api.d.ts +25 -0
  65. package/lib/modules/api.js +98 -0
  66. package/lib/modules/index.d.ts +4 -0
  67. package/lib/modules/index.js +24 -0
  68. package/lib/modules/routes.d.ts +14 -0
  69. package/lib/modules/routes.js +21 -0
  70. package/lib/modules/types.d.ts +68 -0
  71. package/lib/modules/types.js +5 -0
  72. package/lib/pluginFiles.d.ts +28 -0
  73. package/lib/pluginFiles.js +101 -0
  74. package/lib/plugins.d.ts +17 -0
  75. package/lib/plugins.js +93 -0
  76. package/lib/richText.d.ts +20 -0
  77. package/lib/richText.js +99 -0
  78. package/lib/router.d.ts +9 -0
  79. package/lib/router.js +34 -25
  80. package/lib/runner.d.ts +149 -0
  81. package/lib/runner.js +353 -0
  82. package/lib/runs.d.ts +27 -0
  83. package/lib/runs.js +13 -0
  84. package/lib/template.js +7 -9
  85. package/lib/templates/uagComponentNotFound.md +5 -5
  86. package/lib/testing.d.ts +74 -0
  87. package/lib/testing.js +180 -0
  88. package/lib/tools/SchemaBuilder.d.ts +3 -3
  89. package/lib/tools/SchemaBuilder.js +4 -4
  90. package/lib/tools/agentDecide.d.ts +8 -0
  91. package/lib/tools/agentDecide.js +57 -0
  92. package/lib/tools/agentProvide.d.ts +8 -0
  93. package/lib/tools/agentProvide.js +97 -0
  94. package/lib/tools/collectData.js +3 -2
  95. package/lib/tools/confirmSubmission.js +1 -1
  96. package/lib/tools/fetchExternalData.js +8 -17
  97. package/lib/tools/findSubmission.js +62 -56
  98. package/lib/tools/getFieldInfo.js +1 -1
  99. package/lib/tools/getFormFields.js +1 -1
  100. package/lib/tools/getForms.js +1 -1
  101. package/lib/tools/index.d.ts +4 -1
  102. package/lib/tools/index.js +21 -14
  103. package/lib/tools/readDocument.d.ts +17 -0
  104. package/lib/tools/readDocument.js +99 -0
  105. package/lib/tools/submissionUpdate.js +4 -3
  106. package/lib/tools/submitForm.js +1 -1
  107. package/lib/tools/utils.d.ts +40 -0
  108. package/lib/tools/utils.js +16 -1
  109. package/lib/version.d.ts +2 -0
  110. package/lib/version.js +5 -0
  111. package/package.json +49 -19
  112. package/testing.d.ts +1 -0
  113. package/testing.js +2 -0
  114. package/lib/tools/agentProvideData.d.ts +0 -3
  115. package/lib/tools/agentProvideData.js +0 -62
@@ -0,0 +1,70 @@
1
+ import { Collection, Db, MongoClient, MongoClientOptions } from 'mongodb';
2
+ import type { AuthCodeStore } from '@formio/appserver';
3
+ /**
4
+ * The UAG's own database (MONGO, with the database named by UAG_DATABASE). It
5
+ * holds only the UAG's state: authorization codes for remote MCP clients, and
6
+ * task-run records. Never submissions.
7
+ *
8
+ * Every collection the UAG uses is named `uag-*`, and nothing else in the
9
+ * database is read or written, so the database may be shared. Before writing
10
+ * anything, the UAG checks that the `uag-*` collections are its own.
11
+ */
12
+ /** The schema version this UAG writes. Older databases are migrated; newer ones refused. */
13
+ export declare const UAG_SCHEMA_VERSION = 1;
14
+ export declare const UAG_COLLECTIONS: Readonly<{
15
+ meta: "uag-meta";
16
+ authCodes: "uag-authcodes";
17
+ taskRuns: "uag-taskruns";
18
+ }>;
19
+ export type UAGDatabaseOptions = {
20
+ /** The connection string (MONGO). Its database, if any, is ignored: UAG_DATABASE names the one used. */
21
+ url: string;
22
+ /** The database to use (UAG_DATABASE). */
23
+ name?: string;
24
+ /** Driver options (MONGO_CONFIG), as JSON or an object. */
25
+ config?: string | MongoClientOptions;
26
+ /** Days a task-run record is kept (TASK_RUN_RETENTION_DAYS); 0 keeps it forever. */
27
+ retentionDays?: number;
28
+ };
29
+ /** Why the UAG may not use a database. Startup stops with this message. */
30
+ export declare class UAGDatabaseError extends Error {
31
+ name: string;
32
+ }
33
+ export declare class UAGDatabase {
34
+ readonly client: MongoClient;
35
+ readonly db: Db;
36
+ readonly name: string;
37
+ readonly retentionDays: number;
38
+ private bootstrapped?;
39
+ private warned;
40
+ constructor(client: MongoClient, db: Db, options: UAGDatabaseOptions);
41
+ /**
42
+ * Connect, and select the database by name on the connection: the
43
+ * connection string's database and `authSource` are left as they are.
44
+ */
45
+ static connect(options: UAGDatabaseOptions): Promise<UAGDatabase>;
46
+ get meta(): Collection<any>;
47
+ get authCodes(): Collection<any>;
48
+ get taskRuns(): Collection<any>;
49
+ /**
50
+ * Check the database without writing anything: whether the UAG may use it,
51
+ * and what bootstrap would do. Refuses (throws) a database whose `uag-*`
52
+ * collections the UAG did not create, or that a newer UAG created.
53
+ */
54
+ inspect(): Promise<'fresh' | 'ready' | 'older'>;
55
+ /**
56
+ * Check the database (see inspect), then create what is missing: the schema
57
+ * marker first, then the collections and their indexes; or migrate an older
58
+ * schema. Safe to call again.
59
+ */
60
+ bootstrap(): Promise<'created' | 'ready' | 'migrated'>;
61
+ /** Create the collections and indexes, as needed; safe to repeat. */
62
+ private ensureCollections;
63
+ /** The task-run records' TTL index, following TASK_RUN_RETENTION_DAYS; none when it is 0. */
64
+ private ensureRetention;
65
+ /** Run database work, reporting a missing privilege with the database's name. */
66
+ private privileged;
67
+ /** Authorization codes for remote MCP clients (MCP_OAUTH), in `uag-authcodes`. */
68
+ authCodeStore(): AuthCodeStore;
69
+ close(): Promise<void>;
70
+ }
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UAGDatabase = exports.UAGDatabaseError = exports.UAG_COLLECTIONS = exports.UAG_SCHEMA_VERSION = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const mongodb_1 = require("mongodb");
6
+ const debug = require('debug')('formio:uag:database');
7
+ /**
8
+ * The UAG's own database (MONGO, with the database named by UAG_DATABASE). It
9
+ * holds only the UAG's state: authorization codes for remote MCP clients, and
10
+ * task-run records. Never submissions.
11
+ *
12
+ * Every collection the UAG uses is named `uag-*`, and nothing else in the
13
+ * database is read or written, so the database may be shared. Before writing
14
+ * anything, the UAG checks that the `uag-*` collections are its own.
15
+ */
16
+ /** The schema version this UAG writes. Older databases are migrated; newer ones refused. */
17
+ exports.UAG_SCHEMA_VERSION = 1;
18
+ exports.UAG_COLLECTIONS = Object.freeze({
19
+ meta: 'uag-meta',
20
+ authCodes: 'uag-authcodes',
21
+ taskRuns: 'uag-taskruns',
22
+ });
23
+ const PREFIX = 'uag-';
24
+ const APP = 'formio-uag';
25
+ const SCHEMA_ID = 'schema';
26
+ const RETENTION_INDEX = 'retention';
27
+ const DEFAULT_RETENTION_DAYS = 90;
28
+ // MongoDB's "Unauthorized" error.
29
+ const UNAUTHORIZED = 13;
30
+ /** Why the UAG may not use a database. Startup stops with this message. */
31
+ class UAGDatabaseError extends Error {
32
+ constructor() {
33
+ super(...arguments);
34
+ this.name = 'UAGDatabaseError';
35
+ }
36
+ }
37
+ exports.UAGDatabaseError = UAGDatabaseError;
38
+ function driverOptions(config) {
39
+ if (!config)
40
+ return {};
41
+ if (typeof config !== 'string')
42
+ return config;
43
+ try {
44
+ return JSON.parse(config);
45
+ }
46
+ catch (err) {
47
+ throw new UAGDatabaseError(`MONGO_CONFIG is not valid JSON: ${err.message}`);
48
+ }
49
+ }
50
+ /** Authorization codes are stored by their hash, so the database never holds a usable code. */
51
+ const codeKey = (code) => (0, crypto_1.createHash)('sha256').update(code).digest('base64url');
52
+ /**
53
+ * An authorization code's token, sealed with a key only the code gives: the
54
+ * database holds neither the code nor a token it could use.
55
+ */
56
+ const sealKey = (code) => (0, crypto_1.createHash)('sha256').update(`uag-authcode:${code}`).digest();
57
+ function sealToken(code, token) {
58
+ const iv = (0, crypto_1.randomBytes)(12);
59
+ const cipher = (0, crypto_1.createCipheriv)('aes-256-gcm', sealKey(code), iv);
60
+ const sealed = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
61
+ return [iv, cipher.getAuthTag(), sealed].map((part) => part.toString('base64url')).join('.');
62
+ }
63
+ function openToken(code, sealed) {
64
+ try {
65
+ const [iv, tag, data] = sealed.split('.').map((part) => Buffer.from(part, 'base64url'));
66
+ const decipher = (0, crypto_1.createDecipheriv)('aes-256-gcm', sealKey(code), iv);
67
+ decipher.setAuthTag(tag);
68
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ class UAGDatabase {
75
+ constructor(client, db, options) {
76
+ this.client = client;
77
+ this.db = db;
78
+ this.warned = false;
79
+ this.name = db.databaseName;
80
+ const days = options.retentionDays;
81
+ this.retentionDays = days === undefined || Number.isNaN(days) ? DEFAULT_RETENTION_DAYS : Math.max(0, days);
82
+ }
83
+ /**
84
+ * Connect, and select the database by name on the connection: the
85
+ * connection string's database and `authSource` are left as they are.
86
+ */
87
+ static async connect(options) {
88
+ const name = options.name || 'uag';
89
+ const client = new mongodb_1.MongoClient(options.url, driverOptions(options.config));
90
+ try {
91
+ await client.connect();
92
+ }
93
+ catch (err) {
94
+ throw new UAGDatabaseError(`Could not connect to MONGO: ${err.message}`);
95
+ }
96
+ return new UAGDatabase(client, client.db(name), { ...options, name });
97
+ }
98
+ get meta() { return this.db.collection(exports.UAG_COLLECTIONS.meta); }
99
+ get authCodes() { return this.db.collection(exports.UAG_COLLECTIONS.authCodes); }
100
+ get taskRuns() { return this.db.collection(exports.UAG_COLLECTIONS.taskRuns); }
101
+ /**
102
+ * Check the database without writing anything: whether the UAG may use it,
103
+ * and what bootstrap would do. Refuses (throws) a database whose `uag-*`
104
+ * collections the UAG did not create, or that a newer UAG created.
105
+ */
106
+ async inspect() {
107
+ return this.privileged(async () => {
108
+ const names = (await this.db.listCollections({}, { nameOnly: true }).toArray()).map((info) => info.name);
109
+ const ours = names.filter((name) => name.startsWith(PREFIX));
110
+ if (names.length > ours.length && !this.warned) {
111
+ this.warned = true;
112
+ console.warn(`[formio-uag] The database "${this.name}" holds other data too. The UAG uses only its uag-* collections there, ` +
113
+ 'but a database of its own is recommended (UAG_DATABASE).');
114
+ }
115
+ if (!ours.length)
116
+ return 'fresh';
117
+ const marker = ours.includes(exports.UAG_COLLECTIONS.meta) ? await this.meta.findOne({ _id: SCHEMA_ID }) : null;
118
+ if (!marker || marker.app !== APP || typeof marker.version !== 'number') {
119
+ throw new UAGDatabaseError(`The database "${this.name}" holds uag-* collections that the UAG did not create (${ours.join(', ')}). ` +
120
+ 'Nothing was changed. Choose another database with UAG_DATABASE.');
121
+ }
122
+ if (marker.version > exports.UAG_SCHEMA_VERSION) {
123
+ throw new UAGDatabaseError(`The database "${this.name}" was created by a newer UAG (schema version ${marker.version}; ` +
124
+ `this UAG supports ${exports.UAG_SCHEMA_VERSION}). Upgrade the UAG, or choose another database with UAG_DATABASE.`);
125
+ }
126
+ return marker.version < exports.UAG_SCHEMA_VERSION ? 'older' : 'ready';
127
+ });
128
+ }
129
+ /**
130
+ * Check the database (see inspect), then create what is missing: the schema
131
+ * marker first, then the collections and their indexes; or migrate an older
132
+ * schema. Safe to call again.
133
+ */
134
+ bootstrap() {
135
+ this.bootstrapped = this.bootstrapped || this.privileged(async () => {
136
+ const state = await this.inspect();
137
+ if (state === 'fresh') {
138
+ // The marker first: a start interrupted after it finishes the rest next time.
139
+ await this.meta.insertOne({ _id: SCHEMA_ID, app: APP, version: exports.UAG_SCHEMA_VERSION, created: new Date() });
140
+ await this.ensureCollections();
141
+ debug(`Created the UAG's collections in "${this.name}".`);
142
+ return 'created';
143
+ }
144
+ await this.ensureCollections();
145
+ if (state === 'older') {
146
+ // No migrations yet: each schema version adds its steps here.
147
+ await this.meta.updateOne({ _id: SCHEMA_ID }, { $set: { version: exports.UAG_SCHEMA_VERSION, migrated: new Date() } });
148
+ return 'migrated';
149
+ }
150
+ return 'ready';
151
+ });
152
+ // A failed attempt can be tried again.
153
+ this.bootstrapped.catch(() => { this.bootstrapped = undefined; });
154
+ return this.bootstrapped;
155
+ }
156
+ /** Create the collections and indexes, as needed; safe to repeat. */
157
+ async ensureCollections() {
158
+ await this.authCodes.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, name: 'expires' });
159
+ await this.taskRuns.createIndex({ status: 1, leaseUntil: 1 }, { name: 'claim' });
160
+ await this.taskRuns.createIndex({ project: 1, form: 1, submission: 1, created: -1 }, { name: 'submission' });
161
+ await this.ensureRetention();
162
+ }
163
+ /** The task-run records' TTL index, following TASK_RUN_RETENTION_DAYS; none when it is 0. */
164
+ async ensureRetention() {
165
+ const seconds = this.retentionDays * 24 * 60 * 60;
166
+ const indexes = await this.taskRuns.listIndexes().toArray().catch(() => []);
167
+ const existing = indexes.find((index) => index.name === RETENTION_INDEX);
168
+ if (!seconds) {
169
+ if (existing)
170
+ await this.taskRuns.dropIndex(RETENTION_INDEX);
171
+ return;
172
+ }
173
+ if (!existing) {
174
+ await this.taskRuns.createIndex({ created: 1 }, { expireAfterSeconds: seconds, name: RETENTION_INDEX });
175
+ }
176
+ else if (existing.expireAfterSeconds !== seconds) {
177
+ await this.db.command({ collMod: exports.UAG_COLLECTIONS.taskRuns, index: { name: RETENTION_INDEX, expireAfterSeconds: seconds } });
178
+ }
179
+ }
180
+ /** Run database work, reporting a missing privilege with the database's name. */
181
+ async privileged(work) {
182
+ try {
183
+ return await work();
184
+ }
185
+ catch (err) {
186
+ if (err?.code === UNAUTHORIZED) {
187
+ throw new UAGDatabaseError(`The MONGO user may not use the database "${this.name}" (${err.message}). ` +
188
+ `Grant it the readWrite role on "${this.name}", or choose another database with UAG_DATABASE.`);
189
+ }
190
+ throw err;
191
+ }
192
+ }
193
+ /** Authorization codes for remote MCP clients (MCP_OAUTH), in `uag-authcodes`. */
194
+ authCodeStore() {
195
+ const collection = this.authCodes;
196
+ return {
197
+ async save(code, record, ttl) {
198
+ await collection.insertOne({ _id: codeKey(code), sealed: sealToken(code, record.jwt), codeChallenge: record.codeChallenge, expiresAt: new Date(Date.now() + ttl * 1000) });
199
+ },
200
+ async take(code) {
201
+ // The TTL monitor runs about once a minute, so expiry is checked here too.
202
+ const found = await collection.findOneAndDelete({ _id: codeKey(code), expiresAt: { $gt: new Date() } });
203
+ const jwt = typeof found?.sealed === 'string' ? openToken(code, found.sealed) : null;
204
+ return jwt ? { jwt, codeChallenge: found.codeChallenge } : null;
205
+ }
206
+ };
207
+ }
208
+ async close() {
209
+ await this.client.close();
210
+ }
211
+ }
212
+ exports.UAGDatabase = UAGDatabase;
@@ -0,0 +1,38 @@
1
+ import type { PluginContext } from '@formio/appserver';
2
+ import type { Converter, ConverterOutput, Representation } from './types';
3
+ export declare const CONVERTER_DEFAULTS: {
4
+ maxBytes: number;
5
+ timeoutMs: number;
6
+ concurrency: number;
7
+ };
8
+ /** A conversion that did not complete. The message names the converter and the file, never its content. */
9
+ export declare class ConversionError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export type ConvertOptions = {
13
+ to: Representation;
14
+ settings?: Record<string, any>;
15
+ signal?: AbortSignal;
16
+ log: PluginContext['log'];
17
+ };
18
+ /**
19
+ * Run one conversion: under the converter's size limit, in a temporary
20
+ * directory made for the call and removed after it, aborted at its timeout,
21
+ * and queued behind its other conversions beyond its concurrency.
22
+ */
23
+ export declare function runConverter(converter: Converter, input: {
24
+ buffer: Buffer;
25
+ type: string;
26
+ name: string;
27
+ }, options: ConvertOptions): Promise<ConverterOutput>;
28
+ /**
29
+ * Run a route: each step's output feeds the next (an intermediate step always
30
+ * makes a PDF), with each converter's operator settings.
31
+ */
32
+ export declare function runRoute(steps: Converter[], input: {
33
+ buffer: Buffer;
34
+ type: string;
35
+ name: string;
36
+ }, to: Representation, options: Omit<ConvertOptions, 'to' | 'settings'> & {
37
+ settingsOf?: (converter: Converter) => Record<string, any> | undefined;
38
+ }): Promise<ConverterOutput>;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ConversionError = exports.CONVERTER_DEFAULTS = void 0;
7
+ exports.runConverter = runConverter;
8
+ exports.runRoute = runRoute;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const path_1 = __importDefault(require("path"));
12
+ exports.CONVERTER_DEFAULTS = { maxBytes: 25 * 1024 * 1024, timeoutMs: 120000, concurrency: 2 };
13
+ /** A conversion that did not complete. The message names the converter and the file, never its content. */
14
+ class ConversionError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'ConversionError';
18
+ }
19
+ }
20
+ exports.ConversionError = ConversionError;
21
+ /**
22
+ * A queue per converter, so at most `concurrency` of its conversions run at
23
+ * once. A slot is held until its conversion has actually stopped: a converter
24
+ * that ignores its signal still counts after its caller has given up on it.
25
+ */
26
+ class Queue {
27
+ constructor(limit) {
28
+ this.limit = limit;
29
+ this.running = 0;
30
+ this.waiting = [];
31
+ }
32
+ /** Wait for a slot; the returned function frees it (once). */
33
+ async acquire(signal) {
34
+ signal?.throwIfAborted();
35
+ if (this.running < this.limit) {
36
+ this.running++;
37
+ }
38
+ else {
39
+ // A freed slot passes straight to the next waiter, so no new caller can take it in between.
40
+ await new Promise((resolve, reject) => {
41
+ const go = () => { signal?.removeEventListener('abort', cancel); resolve(); };
42
+ const cancel = () => { this.waiting = this.waiting.filter((entry) => entry !== go); reject(signal.reason ?? new Error('aborted')); };
43
+ this.waiting.push(go);
44
+ signal?.addEventListener('abort', cancel, { once: true });
45
+ });
46
+ }
47
+ let released = false;
48
+ return () => {
49
+ if (released)
50
+ return;
51
+ released = true;
52
+ const next = this.waiting.shift();
53
+ if (next)
54
+ next();
55
+ else
56
+ this.running--;
57
+ };
58
+ }
59
+ }
60
+ const queues = new WeakMap();
61
+ /**
62
+ * Run one conversion: under the converter's size limit, in a temporary
63
+ * directory made for the call and removed after it, aborted at its timeout,
64
+ * and queued behind its other conversions beyond its concurrency.
65
+ */
66
+ async function runConverter(converter, input, options) {
67
+ const limits = { ...exports.CONVERTER_DEFAULTS, ...(converter.limits || {}) };
68
+ if (input.buffer.length > limits.maxBytes) {
69
+ throw new ConversionError(`"${input.name}" is larger than the ${converter.name} converter takes (${limits.maxBytes} bytes).`);
70
+ }
71
+ let queue = queues.get(converter);
72
+ if (!queue) {
73
+ queue = new Queue(Math.max(1, limits.concurrency));
74
+ queues.set(converter, queue);
75
+ }
76
+ const release = await queue.acquire(options.signal);
77
+ let tmpdir;
78
+ try {
79
+ options.signal?.throwIfAborted();
80
+ tmpdir = await fs_1.default.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'uag-convert-'));
81
+ }
82
+ catch (err) {
83
+ release();
84
+ throw err;
85
+ }
86
+ const controller = new AbortController();
87
+ const abort = () => controller.abort(options.signal?.reason);
88
+ options.signal?.addEventListener('abort', abort, { once: true });
89
+ if (options.signal?.aborted)
90
+ abort();
91
+ const work = Promise.resolve().then(() => converter.convert(input, { to: options.to, settings: options.settings || {}, signal: controller.signal, tmpdir, log: options.log }));
92
+ // The slot and the temporary directory are kept until convert() settles, even after a timeout or an abort.
93
+ const stopped = work.then(() => undefined, () => undefined).then(async () => {
94
+ await fs_1.default.promises.rm(tmpdir, { recursive: true, force: true }).catch(() => undefined);
95
+ release();
96
+ });
97
+ let timer;
98
+ let finished = false;
99
+ try {
100
+ const timeout = new Promise((_, reject) => {
101
+ timer = setTimeout(() => {
102
+ const error = new ConversionError(`Converting "${input.name}" with ${converter.name} took longer than ${limits.timeoutMs} ms.`);
103
+ // Abort first: a converter running a process stops it on its signal.
104
+ controller.abort(error);
105
+ reject(error);
106
+ }, limits.timeoutMs);
107
+ });
108
+ const aborted = new Promise((_, reject) => {
109
+ if (controller.signal.aborted)
110
+ reject(controller.signal.reason);
111
+ controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
112
+ });
113
+ const output = await Promise.race([work.finally(() => { finished = true; }), timeout, aborted]);
114
+ return checkOutput(converter, input.name, options.to, output);
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ options.signal?.removeEventListener('abort', abort);
119
+ // A conversion that completed has its directory removed before its result is used.
120
+ if (finished)
121
+ await stopped;
122
+ }
123
+ }
124
+ function checkOutput(converter, name, to, output) {
125
+ if (to === 'pdf') {
126
+ if (!output || !Buffer.isBuffer(output.pdf))
127
+ throw new ConversionError(`The ${converter.name} converter did not produce a PDF for "${name}".`);
128
+ return output;
129
+ }
130
+ if (!output || typeof output.text !== 'string' || !['plain', 'markdown', 'csv'].includes(output.textFormat)) {
131
+ throw new ConversionError(`The ${converter.name} converter did not produce text for "${name}".`);
132
+ }
133
+ return output;
134
+ }
135
+ /**
136
+ * Run a route: each step's output feeds the next (an intermediate step always
137
+ * makes a PDF), with each converter's operator settings.
138
+ */
139
+ async function runRoute(steps, input, to, options) {
140
+ let current = input;
141
+ let output;
142
+ for (let i = 0; i < steps.length; i++) {
143
+ const target = i === steps.length - 1 ? to : 'pdf';
144
+ output = await runConverter(steps[i], current, { ...options, to: target, settings: options.settingsOf?.(steps[i]) });
145
+ if (target === 'pdf')
146
+ current = { buffer: output.pdf, type: 'application/pdf', name: input.name };
147
+ }
148
+ if (!output)
149
+ throw new ConversionError(`No converter step for "${input.name}".`);
150
+ return output;
151
+ }
@@ -0,0 +1,28 @@
1
+ import type { Converter } from './types';
2
+ /** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
3
+ export declare function loadUnpdf(): Promise<typeof import('unpdf')>;
4
+ /** pdf-text: a PDF's text layer, page by page, with unpdf. */
5
+ export declare const pdfTextConverter: Converter;
6
+ /** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
7
+ export declare function decodeText(buffer: Buffer): string;
8
+ /** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
9
+ export declare const textConverter: Converter;
10
+ /**
11
+ * The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
12
+ * decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
13
+ * count is checked from its header first: a small, highly compressed file can
14
+ * describe an image far larger than its size suggests. JPEGs are embedded
15
+ * without being decoded. The converter's `maxPixels` setting changes it
16
+ * (config.plugins.settings.converter['image-pdf'].maxPixels, or
17
+ * DOCUMENTS_MAX_IMAGE_PIXELS).
18
+ */
19
+ export declare const MAX_IMAGE_PIXELS = 50000000;
20
+ /** A PNG's width and height, from its IHDR header; undefined when it has none. */
21
+ export declare function pngSize(buffer: Buffer): {
22
+ width: number;
23
+ height: number;
24
+ } | undefined;
25
+ /** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
26
+ export declare const imagePdfConverter: Converter;
27
+ /** The converters every UAG has. None needs a system binary. */
28
+ export declare const BUILT_IN_CONVERTERS: Converter[];
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BUILT_IN_CONVERTERS = exports.imagePdfConverter = exports.MAX_IMAGE_PIXELS = exports.textConverter = exports.pdfTextConverter = void 0;
4
+ exports.loadUnpdf = loadUnpdf;
5
+ exports.decodeText = decodeText;
6
+ exports.pngSize = pngSize;
7
+ const node_html_markdown_1 = require("node-html-markdown");
8
+ const mime_1 = require("./mime");
9
+ const convert_1 = require("./convert");
10
+ const version_1 = require("../version");
11
+ /**
12
+ * A real dynamic import: TypeScript compiles `import()` in a CommonJS build to
13
+ * `require()`, which cannot load an ES module before Node 22.12.
14
+ */
15
+ const esmImport = new Function('specifier', 'return import(specifier)');
16
+ /** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
17
+ function loadUnpdf() {
18
+ return esmImport('unpdf');
19
+ }
20
+ /** pdf-text: a PDF's text layer, page by page, with unpdf. */
21
+ exports.pdfTextConverter = {
22
+ type: 'converter',
23
+ name: 'pdf-text',
24
+ title: 'PDF text',
25
+ description: 'The text layer of a PDF, page by page. A PDF without one (a scan) yields empty text and a warning.',
26
+ version: version_1.UAG_VERSION,
27
+ apiVersion: 1,
28
+ accepts: [{ type: mime_1.MIME.pdf, ext: ['.pdf'] }],
29
+ produces: ['text'],
30
+ async convert(input, options) {
31
+ options.signal.throwIfAborted();
32
+ const { extractText, getDocumentProxy } = await loadUnpdf();
33
+ const document = await getDocumentProxy(new Uint8Array(input.buffer));
34
+ const { totalPages, text } = await extractText(document, { mergePages: false });
35
+ const pages = text.map((pageText, i) => ({ page: i + 1, text: String(pageText || '').trim() }));
36
+ const all = pages.map((page) => page.text).join('\n\n').trim();
37
+ return {
38
+ text: all,
39
+ textFormat: 'plain',
40
+ pages,
41
+ ...(!all && { warnings: [`"${input.name}" has no text layer (${totalPages} page${totalPages === 1 ? '' : 's'}): it may be a scan.`] })
42
+ };
43
+ }
44
+ };
45
+ /** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
46
+ function decodeText(buffer) {
47
+ if (buffer[0] === 0xff && buffer[1] === 0xfe)
48
+ return new TextDecoder('utf-16le').decode(buffer.subarray(2));
49
+ if (buffer[0] === 0xfe && buffer[1] === 0xff)
50
+ return new TextDecoder('utf-16be').decode(buffer.subarray(2));
51
+ try {
52
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer).replace(/^\uFEFF/, '');
53
+ }
54
+ catch {
55
+ return new TextDecoder('windows-1252').decode(buffer);
56
+ }
57
+ }
58
+ const TEXT_FORMATS = {
59
+ [mime_1.MIME.markdown]: 'markdown',
60
+ [mime_1.MIME.html]: 'markdown',
61
+ [mime_1.MIME.csv]: 'csv'
62
+ };
63
+ /** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
64
+ exports.textConverter = {
65
+ type: 'converter',
66
+ name: 'text',
67
+ title: 'Text',
68
+ description: 'Text files as they are; HTML as Markdown.',
69
+ version: version_1.UAG_VERSION,
70
+ apiVersion: 1,
71
+ accepts: [
72
+ { type: mime_1.MIME.text, ext: ['.txt'] },
73
+ { type: mime_1.MIME.markdown, ext: ['.md', '.markdown'] },
74
+ { type: mime_1.MIME.csv, ext: ['.csv'] },
75
+ { type: mime_1.MIME.json, ext: ['.json'] },
76
+ { type: mime_1.MIME.html, ext: ['.html', '.htm'] },
77
+ { type: mime_1.MIME.xml, ext: ['.xml'] }
78
+ ],
79
+ produces: ['text'],
80
+ async convert(input, options) {
81
+ options.signal.throwIfAborted();
82
+ const decoded = decodeText(input.buffer);
83
+ const text = input.type === mime_1.MIME.html ? node_html_markdown_1.NodeHtmlMarkdown.translate(decoded) : decoded;
84
+ return { text, textFormat: TEXT_FORMATS[input.type] || 'plain' };
85
+ }
86
+ };
87
+ /** A4 portrait, in points. */
88
+ const PAGE = { width: 595.28, height: 841.89, margin: 36 };
89
+ /**
90
+ * The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
91
+ * decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
92
+ * count is checked from its header first: a small, highly compressed file can
93
+ * describe an image far larger than its size suggests. JPEGs are embedded
94
+ * without being decoded. The converter's `maxPixels` setting changes it
95
+ * (config.plugins.settings.converter['image-pdf'].maxPixels, or
96
+ * DOCUMENTS_MAX_IMAGE_PIXELS).
97
+ */
98
+ exports.MAX_IMAGE_PIXELS = 50000000;
99
+ /** A PNG's width and height, from its IHDR header; undefined when it has none. */
100
+ function pngSize(buffer) {
101
+ if (buffer.length < 24 || buffer.toString('latin1', 12, 16) !== 'IHDR')
102
+ return undefined;
103
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
104
+ }
105
+ /** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
106
+ exports.imagePdfConverter = {
107
+ type: 'converter',
108
+ name: 'image-pdf',
109
+ title: 'Image to PDF',
110
+ description: 'A PNG or JPEG image as a one-page PDF, scaled to fit the page.',
111
+ version: version_1.UAG_VERSION,
112
+ apiVersion: 1,
113
+ accepts: [{ type: mime_1.MIME.png, ext: ['.png'] }, { type: mime_1.MIME.jpeg, ext: ['.jpg', '.jpeg'] }],
114
+ produces: ['pdf'],
115
+ settings: {
116
+ maxPixels: {
117
+ type: 'number',
118
+ env: 'DOCUMENTS_MAX_IMAGE_PIXELS',
119
+ default: exports.MAX_IMAGE_PIXELS,
120
+ description: 'The largest PNG, in pixels (width × height), converted; a larger one is refused before it is decoded.'
121
+ }
122
+ },
123
+ async convert(input, options) {
124
+ options.signal.throwIfAborted();
125
+ if (input.type === mime_1.MIME.png) {
126
+ const size = pngSize(input.buffer);
127
+ if (!size)
128
+ throw new convert_1.ConversionError(`"${input.name}" is not a PNG image the image-pdf converter can read.`);
129
+ const maxPixels = Number(options.settings?.maxPixels) > 0 ? Number(options.settings.maxPixels) : exports.MAX_IMAGE_PIXELS;
130
+ if (size.width * size.height > maxPixels) {
131
+ throw new convert_1.ConversionError(`"${input.name}" is ${size.width}×${size.height} pixels, more than the image-pdf converter takes (${maxPixels} pixels).`);
132
+ }
133
+ }
134
+ const { PDFDocument } = require('pdf-lib');
135
+ const pdf = await PDFDocument.create();
136
+ const image = input.type === mime_1.MIME.png ? await pdf.embedPng(input.buffer) : await pdf.embedJpg(input.buffer);
137
+ const landscape = image.width > image.height;
138
+ const width = landscape ? PAGE.height : PAGE.width;
139
+ const height = landscape ? PAGE.width : PAGE.height;
140
+ const scale = Math.min(1, (width - 2 * PAGE.margin) / image.width, (height - 2 * PAGE.margin) / image.height);
141
+ const page = pdf.addPage([width, height]);
142
+ const drawn = { width: image.width * scale, height: image.height * scale };
143
+ // The image is embedded as it is and only drawn smaller: its pixels are never resampled.
144
+ page.drawImage(image, { x: (width - drawn.width) / 2, y: (height - drawn.height) / 2, ...drawn });
145
+ return { pdf: Buffer.from(await pdf.save()), pages: 1 };
146
+ }
147
+ };
148
+ /** The converters every UAG has. None needs a system binary. */
149
+ exports.BUILT_IN_CONVERTERS = [exports.pdfTextConverter, exports.textConverter, exports.imagePdfConverter];