sygal-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2858 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+
25
+ // src/index.ts
26
+ var import_node_fs7 = require("node:fs");
27
+ var import_node_path9 = require("node:path");
28
+ var import_commander = require("commander");
29
+ var import_picocolors3 = __toESM(require("picocolors"));
30
+
31
+ // ../core/src/errors.ts
32
+ var ConversionError = class extends Error {
33
+ constructor(code, message) {
34
+ super(message);
35
+ this.code = code;
36
+ this.name = "ConversionError";
37
+ }
38
+ };
39
+
40
+ // ../core/src/mime.ts
41
+ var MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024;
42
+ var SUPPORTED_EXTENSIONS = {
43
+ // MarkItDown
44
+ pdf: "application/pdf",
45
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
46
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
47
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
48
+ xls: "application/vnd.ms-excel",
49
+ html: "text/html",
50
+ htm: "text/html",
51
+ csv: "text/csv",
52
+ txt: "text/plain",
53
+ md: "text/markdown",
54
+ json: "application/json",
55
+ xml: "application/xml",
56
+ epub: "application/epub+zip",
57
+ msg: "application/vnd.ms-outlook",
58
+ eml: "message/rfc822",
59
+ ipynb: "application/x-ipynb+json",
60
+ zip: "application/zip",
61
+ // OCR (images) — HEIC/TIFF normalized to PNG before the API call (sharp)
62
+ png: "image/png",
63
+ jpg: "image/jpeg",
64
+ jpeg: "image/jpeg",
65
+ gif: "image/gif",
66
+ webp: "image/webp",
67
+ tiff: "image/tiff",
68
+ tif: "image/tiff",
69
+ heic: "image/heic",
70
+ heif: "image/heif"
71
+ };
72
+ function mimeForExtension(extension) {
73
+ return SUPPORTED_EXTENSIONS[extension.toLowerCase()] ?? null;
74
+ }
75
+ function pipelineForMime(mimeType) {
76
+ return mimeType.startsWith("image/") ? "ocr" : "markitdown";
77
+ }
78
+
79
+ // ../core/src/tokens.ts
80
+ var import_gpt_4o = require("gpt-tokenizer/model/gpt-4o");
81
+ function countTokens(text) {
82
+ if (!text) return 0;
83
+ try {
84
+ return (0, import_gpt_4o.encode)(text).length;
85
+ } catch {
86
+ return 0;
87
+ }
88
+ }
89
+
90
+ // ../core/src/image-normalize.ts
91
+ var import_sharp = __toESM(require("sharp"));
92
+ var NEEDS_PNG = /* @__PURE__ */ new Set([".heic", ".heif", ".tiff", ".tif"]);
93
+ function needsPngNormalization(ext) {
94
+ return NEEDS_PNG.has(ext.toLowerCase());
95
+ }
96
+ async function toPngBuffer(input) {
97
+ return (0, import_sharp.default)(input).png().toBuffer();
98
+ }
99
+ async function shrinkToMaxBytes(input, maxBytes) {
100
+ const QUALITY = 80;
101
+ let output = await (0, import_sharp.default)(input).jpeg({ quality: QUALITY }).toBuffer();
102
+ if (output.length <= maxBytes) return output;
103
+ const meta = await (0, import_sharp.default)(input).metadata();
104
+ let width = meta.width ?? 1600;
105
+ while (output.length > maxBytes && width > 200) {
106
+ width = Math.round(width * 0.75);
107
+ output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality: QUALITY }).toBuffer();
108
+ }
109
+ let quality = QUALITY;
110
+ while (output.length > maxBytes && quality > 20) {
111
+ quality -= 15;
112
+ output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality }).toBuffer();
113
+ }
114
+ return output;
115
+ }
116
+
117
+ // ../core/src/ocr-models.ts
118
+ var OCR_MODELS = {
119
+ openai: "gpt-4o",
120
+ mistral: "mistral-ocr-latest",
121
+ claude: "claude-sonnet-4-6",
122
+ // Bascule 18/07/2026 (test comparatif, validation Cyrill) : nemotron nano VL
123
+ // remplace meta/llama-3.2-11b-vision-instruct — 0/5 confabulation sur portrait
124
+ // sans texte (contre 5/5), français fidèle, plus rapide et déterministe.
125
+ nvidia: "nvidia/llama-3.1-nemotron-nano-vl-8b-v1",
126
+ gemini: "gemini-2.0-flash"
127
+ };
128
+ var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
129
+
130
+ // ../core/src/sidecar/manager.ts
131
+ var import_node_child_process = require("node:child_process");
132
+ var import_node_crypto = require("node:crypto");
133
+ var import_node_readline = require("node:readline");
134
+ var SidecarError = class extends Error {
135
+ constructor(code, message) {
136
+ super(message);
137
+ this.code = code;
138
+ this.name = "SidecarError";
139
+ }
140
+ };
141
+ var DEFAULT_TIMEOUT_MS = 12e4;
142
+ var DEFAULT_MAX_RESPAWNS = 3;
143
+ var SidecarManager = class {
144
+ command;
145
+ args;
146
+ requestTimeoutMs;
147
+ maxRespawns;
148
+ logger;
149
+ child = null;
150
+ pending = /* @__PURE__ */ new Map();
151
+ respawnCount = 0;
152
+ stopped = false;
153
+ constructor(options) {
154
+ this.command = options.command;
155
+ this.args = options.args ?? [];
156
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
157
+ this.maxRespawns = options.maxRespawns ?? DEFAULT_MAX_RESPAWNS;
158
+ this.logger = options.logger ?? console;
159
+ }
160
+ get isRunning() {
161
+ return this.child !== null;
162
+ }
163
+ start() {
164
+ if (this.child) return;
165
+ this.stopped = false;
166
+ const child = (0, import_node_child_process.spawn)(this.command, this.args, {
167
+ stdio: ["pipe", "pipe", "pipe"]
168
+ });
169
+ this.child = child;
170
+ const stdout = (0, import_node_readline.createInterface)({ input: child.stdout });
171
+ stdout.on("line", (line) => this.handleLine(line));
172
+ const stderr = (0, import_node_readline.createInterface)({ input: child.stderr });
173
+ stderr.on("line", (line) => this.logger.info(`[sidecar:stderr] ${line}`));
174
+ child.stdin.on("error", (err) => {
175
+ this.logger.warn(`[sidecar] erreur stdin ignor\xE9e : ${err.message}`);
176
+ });
177
+ child.on("error", (err) => {
178
+ this.logger.error(`[sidecar] erreur de spawn : ${err.message}`);
179
+ this.handleExit(null);
180
+ });
181
+ child.on("exit", (code) => {
182
+ if (child !== this.child) return;
183
+ this.handleExit(code);
184
+ });
185
+ }
186
+ /** Arrêt propre : fin de stdin, plus aucun respawn. */
187
+ stop() {
188
+ this.stopped = true;
189
+ const child = this.child;
190
+ this.child = null;
191
+ if (child) {
192
+ child.stdin.end();
193
+ child.kill();
194
+ }
195
+ this.rejectAllPending("SIDECAR_DOWN", "Sidecar arr\xEAt\xE9");
196
+ }
197
+ async ping() {
198
+ await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "ping" });
199
+ }
200
+ async convert(path, options = {}) {
201
+ const response = await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "convert", path, options });
202
+ return {
203
+ markdown: response.markdown ?? "",
204
+ meta: response.meta ?? {},
205
+ originText: response.origin_text,
206
+ originPdf: response.origin_pdf,
207
+ originImages: response.origin_images
208
+ };
209
+ }
210
+ async convertUrl(url) {
211
+ const response = await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "convert_url", url });
212
+ return {
213
+ markdown: response.markdown ?? "",
214
+ meta: response.meta ?? {},
215
+ originText: response.origin_text
216
+ };
217
+ }
218
+ async probePdf(path) {
219
+ const response = await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "pdf_probe", path });
220
+ return {
221
+ pageCount: response.page_count ?? 0,
222
+ pagesTextLength: response.pages_text_length ?? []
223
+ };
224
+ }
225
+ async renderPdfPage(path, page) {
226
+ const response = await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "pdf_render_page", path, page });
227
+ if (!response.image_path) {
228
+ throw new SidecarError("CONVERSION_FAILED", "Rendu de page PDF sans image_path");
229
+ }
230
+ return response.image_path;
231
+ }
232
+ request(req) {
233
+ const child = this.child;
234
+ if (!child || child.killed || child.stdin.destroyed || !child.stdin.writable) {
235
+ return Promise.reject(
236
+ new SidecarError("SIDECAR_DOWN", "Sidecar non d\xE9marr\xE9 ou d\xE9finitivement arr\xEAt\xE9")
237
+ );
238
+ }
239
+ return new Promise((resolve2, reject) => {
240
+ const timer = setTimeout(() => {
241
+ this.pending.delete(req.id);
242
+ reject(new SidecarError("TIMEOUT", `Timeout apr\xE8s ${this.requestTimeoutMs} ms`));
243
+ }, this.requestTimeoutMs);
244
+ this.pending.set(req.id, {
245
+ resolve: (response) => {
246
+ clearTimeout(timer);
247
+ if (response.status === "ok") {
248
+ resolve2(response);
249
+ } else {
250
+ reject(new SidecarError(response.code, response.message));
251
+ }
252
+ },
253
+ reject: (error) => {
254
+ clearTimeout(timer);
255
+ reject(error);
256
+ },
257
+ timer
258
+ });
259
+ try {
260
+ child.stdin.write(JSON.stringify(req) + "\n");
261
+ } catch (err) {
262
+ this.pending.delete(req.id);
263
+ clearTimeout(timer);
264
+ reject(
265
+ new SidecarError(
266
+ "SIDECAR_DOWN",
267
+ `\xC9criture stdin impossible : ${err instanceof Error ? err.message : String(err)}`
268
+ )
269
+ );
270
+ }
271
+ });
272
+ }
273
+ handleLine(line) {
274
+ let response;
275
+ try {
276
+ response = JSON.parse(line);
277
+ } catch {
278
+ this.logger.warn(`[sidecar] ligne stdout non-JSON ignor\xE9e : ${line}`);
279
+ return;
280
+ }
281
+ if (!response.id) {
282
+ this.logger.warn(`[sidecar] r\xE9ponse sans id : ${line}`);
283
+ return;
284
+ }
285
+ const pending = this.pending.get(response.id);
286
+ if (!pending) {
287
+ this.logger.warn(`[sidecar] r\xE9ponse orpheline (id=${response.id})`);
288
+ return;
289
+ }
290
+ this.pending.delete(response.id);
291
+ this.respawnCount = 0;
292
+ pending.resolve(response);
293
+ }
294
+ handleExit(code) {
295
+ this.child = null;
296
+ this.rejectAllPending("SIDECAR_DOWN", `Sidecar termin\xE9 (code ${code ?? "inconnu"})`);
297
+ if (this.stopped) return;
298
+ if (this.respawnCount >= this.maxRespawns) {
299
+ this.logger.error(`[sidecar] crash d\xE9finitif apr\xE8s ${this.maxRespawns} tentatives de respawn`);
300
+ return;
301
+ }
302
+ this.respawnCount += 1;
303
+ this.logger.warn(
304
+ `[sidecar] crash (code ${code ?? "inconnu"}) \u2014 respawn ${this.respawnCount}/${this.maxRespawns}`
305
+ );
306
+ this.start();
307
+ }
308
+ rejectAllPending(code, message) {
309
+ for (const pending of this.pending.values()) {
310
+ clearTimeout(pending.timer);
311
+ pending.reject(new SidecarError(code, message));
312
+ }
313
+ this.pending.clear();
314
+ }
315
+ };
316
+
317
+ // ../core/src/sidecar/pool.ts
318
+ var DEFAULT_POOL_SIZE = 2;
319
+ var SidecarPool = class {
320
+ workers;
321
+ inFlight;
322
+ constructor(options) {
323
+ const size = options.poolSize ?? DEFAULT_POOL_SIZE;
324
+ this.workers = Array.from({ length: size }, () => new SidecarManager(options));
325
+ this.inFlight = new Map(this.workers.map((w) => [w, 0]));
326
+ }
327
+ /** Vrai tant qu'au moins un worker est vivant */
328
+ get isRunning() {
329
+ return this.workers.some((w) => w.isRunning);
330
+ }
331
+ start() {
332
+ for (const worker of this.workers) worker.start();
333
+ }
334
+ stop() {
335
+ for (const worker of this.workers) worker.stop();
336
+ }
337
+ /** Ping de tous les workers vivants (health check au boot) */
338
+ async ping() {
339
+ const alive = this.workers.filter((w) => w.isRunning);
340
+ if (alive.length === 0) {
341
+ throw new SidecarError("SIDECAR_DOWN", "Aucun worker sidecar vivant");
342
+ }
343
+ await Promise.all(alive.map((w) => w.ping()));
344
+ }
345
+ async convert(path, options = {}) {
346
+ const worker = this.pickWorker();
347
+ this.inFlight.set(worker, (this.inFlight.get(worker) ?? 0) + 1);
348
+ try {
349
+ return await worker.convert(path, options);
350
+ } finally {
351
+ this.inFlight.set(worker, Math.max(0, (this.inFlight.get(worker) ?? 1) - 1));
352
+ }
353
+ }
354
+ async convertUrl(url) {
355
+ const worker = this.pickWorker();
356
+ this.inFlight.set(worker, (this.inFlight.get(worker) ?? 0) + 1);
357
+ try {
358
+ return await worker.convertUrl(url);
359
+ } finally {
360
+ this.inFlight.set(worker, Math.max(0, (this.inFlight.get(worker) ?? 1) - 1));
361
+ }
362
+ }
363
+ async probePdf(path) {
364
+ const worker = this.pickWorker();
365
+ this.inFlight.set(worker, (this.inFlight.get(worker) ?? 0) + 1);
366
+ try {
367
+ return await worker.probePdf(path);
368
+ } finally {
369
+ this.inFlight.set(worker, Math.max(0, (this.inFlight.get(worker) ?? 1) - 1));
370
+ }
371
+ }
372
+ async renderPdfPage(path, page) {
373
+ const worker = this.pickWorker();
374
+ this.inFlight.set(worker, (this.inFlight.get(worker) ?? 0) + 1);
375
+ try {
376
+ return await worker.renderPdfPage(path, page);
377
+ } finally {
378
+ this.inFlight.set(worker, Math.max(0, (this.inFlight.get(worker) ?? 1) - 1));
379
+ }
380
+ }
381
+ /** Worker vivant le moins chargé — les workers morts sont exclus du dispatch */
382
+ pickWorker() {
383
+ let best = null;
384
+ let bestLoad = Infinity;
385
+ for (const worker of this.workers) {
386
+ if (!worker.isRunning) continue;
387
+ const load2 = this.inFlight.get(worker) ?? 0;
388
+ if (load2 < bestLoad) {
389
+ best = worker;
390
+ bestLoad = load2;
391
+ }
392
+ }
393
+ if (!best) {
394
+ throw new SidecarError("SIDECAR_DOWN", "Aucun worker sidecar vivant");
395
+ }
396
+ return best;
397
+ }
398
+ };
399
+
400
+ // ../core/src/ocr/ocr-provider.ts
401
+ var LOCALE_LANGUAGE = {
402
+ fr: "French",
403
+ en: "English",
404
+ it: "Italian",
405
+ de: "German",
406
+ es: "Spanish"
407
+ };
408
+ function buildOcrPrompt(labelLanguage = "French") {
409
+ return `Transcribe ALL visible text in this image verbatim as clean Markdown, preserving the document layout: headings, lists and tables exactly as shown. Transcribe EVERY line, row and table cell faithfully \u2014 never summarize, skip, merge, reorder, or invent rows, values, or text. If a cell is empty, leave it empty. Keep the document text in its ORIGINAL language exactly as written \u2014 do not translate it. Do NOT invent a table of contents, section titles, or any heading/structure that is not visually present in the document. If you must add a label of your own that is not in the document, write it in ${labelLanguage}. While transcribing, do NOT describe the scene, the people, or any visual elements that are not text. ONLY IF the image contains no readable text at all: instead of an empty document, describe the image in ${labelLanguage} as clean Markdown \u2014 one short paragraph covering the subject, the setting and the dominant colors, factual and concise, without inventing any text that is not visible. When describing, NEVER invent or guess names, ages, occupations, employers, biographies, locations, dates, or any information about people or organizations. Describe only what is physically visible in the image; if something is not visible, do not mention it. Output only the Markdown.`;
410
+ }
411
+ function buildTranslatePrompt(targetLocale) {
412
+ const language = LOCALE_LANGUAGE[targetLocale] ?? "English";
413
+ return `Translate the ENTIRE following Markdown document into ${language}, from the first line to the last, without skipping any paragraph, list item, or table cell. Every word of human-readable text must end up in ${language} \u2014 none of the original language may remain anywhere in the output. Preserve the Markdown structure exactly (headings, lists, tables, code blocks, links) and keep URLs, code, and proper nouns unchanged. Output only the fully translated Markdown, nothing else.`;
414
+ }
415
+ var OcrError = class extends Error {
416
+ constructor(code, message, retryable = false) {
417
+ super(message);
418
+ this.code = code;
419
+ this.retryable = retryable;
420
+ this.name = "OcrError";
421
+ }
422
+ };
423
+ function httpStatusToOcrError(status, providerId) {
424
+ if (status === 401 || status === 403) {
425
+ return new OcrError("API_KEY_INVALID", `${providerId} : cl\xE9 API refus\xE9e (HTTP ${status})`);
426
+ }
427
+ if (status === 429) {
428
+ return new OcrError("RATE_LIMITED", `${providerId} : limite de requ\xEAtes atteinte (HTTP 429)`);
429
+ }
430
+ if (status >= 500) {
431
+ return new OcrError("NETWORK", `${providerId} : erreur serveur (HTTP ${status})`, true);
432
+ }
433
+ return new OcrError("UNKNOWN", `${providerId} : r\xE9ponse inattendue (HTTP ${status})`);
434
+ }
435
+ function toDataUrl(img, mime) {
436
+ return `data:${mime};base64,${img.toString("base64")}`;
437
+ }
438
+ async function callOpenAiCompatibleChatText(endpoint, model, apiKey, signal, prompt, text, providerId, maxTokens = 8192, frequencyPenalty) {
439
+ const response = await fetch(endpoint, {
440
+ method: "POST",
441
+ signal,
442
+ headers: {
443
+ "Content-Type": "application/json",
444
+ Authorization: `Bearer ${apiKey}`
445
+ },
446
+ body: JSON.stringify({
447
+ model,
448
+ max_tokens: maxTokens,
449
+ ...frequencyPenalty !== void 0 ? { frequency_penalty: frequencyPenalty } : {},
450
+ messages: [{ role: "user", content: `${prompt}
451
+
452
+ ${text}` }]
453
+ })
454
+ });
455
+ if (!response.ok) {
456
+ throw httpStatusToOcrError(response.status, providerId);
457
+ }
458
+ const payload = await response.json();
459
+ const markdown = payload.choices?.[0]?.message?.content;
460
+ if (typeof markdown !== "string" || markdown.length === 0) {
461
+ throw new OcrError("UNKNOWN", `${providerId} : r\xE9ponse sans contenu`);
462
+ }
463
+ return { markdown, raw: payload };
464
+ }
465
+
466
+ // ../core/src/ocr/ocr-runner.ts
467
+ var import_promises = require("node:fs/promises");
468
+ var import_node_path = require("node:path");
469
+
470
+ // ../core/src/ocr/claude-provider.ts
471
+ var CLAUDE_OCR_MODEL = OCR_MODELS.claude;
472
+ var ENDPOINT = "https://api.anthropic.com/v1/messages";
473
+ var claudeProvider = {
474
+ id: "claude",
475
+ model: CLAUDE_OCR_MODEL,
476
+ async convertImage(img, mime, apiKey, signal, prompt) {
477
+ const response = await fetch(ENDPOINT, {
478
+ method: "POST",
479
+ signal,
480
+ headers: {
481
+ "Content-Type": "application/json",
482
+ "x-api-key": apiKey,
483
+ "anthropic-version": "2023-06-01"
484
+ },
485
+ body: JSON.stringify({
486
+ model: CLAUDE_OCR_MODEL,
487
+ max_tokens: 8192,
488
+ messages: [
489
+ {
490
+ role: "user",
491
+ content: [
492
+ {
493
+ type: "image",
494
+ source: { type: "base64", media_type: mime, data: img.toString("base64") }
495
+ },
496
+ { type: "text", text: prompt }
497
+ ]
498
+ }
499
+ ]
500
+ })
501
+ });
502
+ if (!response.ok) throw httpStatusToOcrError(response.status, "claude");
503
+ const payload = await response.json();
504
+ const markdown = (payload.content ?? []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
505
+ if (markdown.length === 0) {
506
+ throw new OcrError("UNKNOWN", "claude : r\xE9ponse sans contenu");
507
+ }
508
+ return { markdown, raw: payload };
509
+ },
510
+ async translateText(text, apiKey, signal, prompt) {
511
+ const response = await fetch(ENDPOINT, {
512
+ method: "POST",
513
+ signal,
514
+ headers: {
515
+ "Content-Type": "application/json",
516
+ "x-api-key": apiKey,
517
+ "anthropic-version": "2023-06-01"
518
+ },
519
+ body: JSON.stringify({
520
+ model: CLAUDE_OCR_MODEL,
521
+ max_tokens: 8192,
522
+ messages: [{ role: "user", content: `${prompt}
523
+
524
+ ${text}` }]
525
+ })
526
+ });
527
+ if (!response.ok) throw httpStatusToOcrError(response.status, "claude");
528
+ const payload = await response.json();
529
+ const markdown = (payload.content ?? []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
530
+ if (markdown.length === 0) {
531
+ throw new OcrError("UNKNOWN", "claude : r\xE9ponse sans contenu");
532
+ }
533
+ return { markdown, raw: payload };
534
+ }
535
+ };
536
+
537
+ // ../core/src/ocr/custom-provider.ts
538
+ async function callCustomProvider(provider, img, mime, apiKey, signal, prompt) {
539
+ const base = provider.baseUrl.replace(/\/$/, "");
540
+ const endpoint = `${base}/chat/completions`;
541
+ const response = await fetch(endpoint, {
542
+ method: "POST",
543
+ signal,
544
+ headers: {
545
+ "Content-Type": "application/json",
546
+ Authorization: `Bearer ${apiKey}`
547
+ },
548
+ body: JSON.stringify({
549
+ model: provider.model,
550
+ max_tokens: 8192,
551
+ messages: [
552
+ {
553
+ role: "user",
554
+ content: [
555
+ { type: "text", text: prompt },
556
+ { type: "image_url", image_url: { url: toDataUrl(img, mime) } }
557
+ ]
558
+ }
559
+ ]
560
+ })
561
+ });
562
+ if (!response.ok) {
563
+ throw httpStatusToOcrError(response.status, "openai");
564
+ }
565
+ const payload = await response.json();
566
+ const markdown = payload.choices?.[0]?.message?.content;
567
+ if (typeof markdown !== "string" || markdown.length === 0) {
568
+ throw new OcrError("UNKNOWN", `${provider.name} : r\xE9ponse sans contenu`);
569
+ }
570
+ return { markdown };
571
+ }
572
+ function callCustomProviderTranslate(provider, text, apiKey, signal, prompt) {
573
+ const base = provider.baseUrl.replace(/\/$/, "");
574
+ return callOpenAiCompatibleChatText(
575
+ `${base}/chat/completions`,
576
+ provider.model,
577
+ apiKey,
578
+ signal,
579
+ prompt,
580
+ text,
581
+ provider.name
582
+ );
583
+ }
584
+
585
+ // ../core/src/ocr/gemini-provider.ts
586
+ var MODEL = OCR_MODELS.gemini;
587
+ var ENDPOINT2 = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
588
+ var geminiProvider = {
589
+ id: "gemini",
590
+ model: MODEL,
591
+ async convertImage(img, mime, apiKey, signal, prompt) {
592
+ const response = await fetch(ENDPOINT2, {
593
+ method: "POST",
594
+ signal,
595
+ headers: {
596
+ "Content-Type": "application/json",
597
+ Authorization: `Bearer ${apiKey}`
598
+ },
599
+ body: JSON.stringify({
600
+ model: MODEL,
601
+ max_tokens: 8192,
602
+ messages: [
603
+ {
604
+ role: "user",
605
+ content: [
606
+ { type: "text", text: prompt },
607
+ { type: "image_url", image_url: { url: toDataUrl(img, mime) } }
608
+ ]
609
+ }
610
+ ]
611
+ })
612
+ });
613
+ if (!response.ok) throw httpStatusToOcrError(response.status, "gemini");
614
+ const payload = await response.json();
615
+ const markdown = payload.choices?.[0]?.message?.content;
616
+ if (typeof markdown !== "string" || markdown.length === 0) {
617
+ throw new OcrError("UNKNOWN", "gemini : r\xE9ponse sans contenu");
618
+ }
619
+ return { markdown, raw: payload };
620
+ },
621
+ translateText(text, apiKey, signal, prompt) {
622
+ return callOpenAiCompatibleChatText(ENDPOINT2, MODEL, apiKey, signal, prompt, text, "gemini");
623
+ }
624
+ };
625
+
626
+ // ../core/src/ocr/mistral-provider.ts
627
+ var MISTRAL_OCR_MODEL = OCR_MODELS.mistral;
628
+ var ENDPOINT3 = "https://api.mistral.ai/v1/ocr";
629
+ var CHAT_ENDPOINT = "https://api.mistral.ai/v1/chat/completions";
630
+ var mistralProvider = {
631
+ id: "mistral",
632
+ model: MISTRAL_OCR_MODEL,
633
+ async convertImage(img, mime, apiKey, signal) {
634
+ const response = await fetch(ENDPOINT3, {
635
+ method: "POST",
636
+ signal,
637
+ headers: {
638
+ "Content-Type": "application/json",
639
+ Authorization: `Bearer ${apiKey}`
640
+ },
641
+ body: JSON.stringify({
642
+ model: MISTRAL_OCR_MODEL,
643
+ document: { type: "image_url", image_url: toDataUrl(img, mime) }
644
+ })
645
+ });
646
+ if (!response.ok) throw httpStatusToOcrError(response.status, "mistral");
647
+ const payload = await response.json();
648
+ const markdown = (payload.pages ?? []).map((p) => p.markdown ?? "").filter((md) => md.length > 0).join("\n\n");
649
+ if (markdown.length === 0) {
650
+ throw new OcrError("UNKNOWN", "mistral : r\xE9ponse sans contenu");
651
+ }
652
+ return { markdown, raw: payload };
653
+ },
654
+ translateText(text, apiKey, signal, prompt) {
655
+ return callOpenAiCompatibleChatText(
656
+ CHAT_ENDPOINT,
657
+ MISTRAL_TRANSLATE_MODEL,
658
+ apiKey,
659
+ signal,
660
+ prompt,
661
+ text,
662
+ "mistral"
663
+ );
664
+ }
665
+ };
666
+
667
+ // ../core/src/ocr/nvidia-provider.ts
668
+ var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
669
+ var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
670
+ var NVIDIA_MAX_IMAGE_BYTES = 17e4;
671
+ var nvidiaProvider = {
672
+ id: "nvidia",
673
+ model: NVIDIA_OCR_MODEL,
674
+ async convertImage(img, mime, apiKey, signal, prompt) {
675
+ let payloadImg = img;
676
+ let payloadMime = mime;
677
+ if (payloadImg.length > NVIDIA_MAX_IMAGE_BYTES) {
678
+ payloadImg = await shrinkToMaxBytes(payloadImg, NVIDIA_MAX_IMAGE_BYTES);
679
+ payloadMime = "image/jpeg";
680
+ }
681
+ const response = await fetch(ENDPOINT4, {
682
+ method: "POST",
683
+ signal,
684
+ headers: {
685
+ "Content-Type": "application/json",
686
+ Authorization: `Bearer ${apiKey}`
687
+ },
688
+ body: JSON.stringify({
689
+ model: NVIDIA_OCR_MODEL,
690
+ max_tokens: 4096,
691
+ // Llama vision peut dégénérer en boucle de répétition sans pénalité
692
+ // (observé en réel : même phrase répétée ~200 fois jusqu'à max_tokens).
693
+ frequency_penalty: 0.4,
694
+ // Petit modèle (11B) : sans température 0 ni cadrage renforcé, il "interprète"
695
+ // (résumé en anglais observé en réel 12/07/2026 au lieu d'une transcription).
696
+ // ATTENTION : pas de message system — le endpoint NIM llama vision rejette
697
+ // les requêtes system + image (constaté en réel 12/07/2026, appel en échec).
698
+ temperature: 0,
699
+ messages: [
700
+ {
701
+ role: "user",
702
+ content: [
703
+ {
704
+ type: "text",
705
+ text: "You are an OCR engine. You copy text from images character by character. You never summarize, never translate, never comment, never add steps or headings of your own. You only describe the image in the single case where it contains no readable text at all, exactly as instructed below.\n\n" + prompt
706
+ },
707
+ { type: "image_url", image_url: { url: toDataUrl(payloadImg, payloadMime) } }
708
+ ]
709
+ }
710
+ ]
711
+ })
712
+ });
713
+ if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
714
+ const payload = await response.json();
715
+ const markdown = payload.choices?.[0]?.message?.content;
716
+ if (typeof markdown !== "string" || markdown.length === 0) {
717
+ throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
718
+ }
719
+ return { markdown, raw: payload };
720
+ },
721
+ translateText(text, apiKey, signal, prompt) {
722
+ return callOpenAiCompatibleChatText(
723
+ ENDPOINT4,
724
+ NVIDIA_OCR_MODEL,
725
+ apiKey,
726
+ signal,
727
+ prompt,
728
+ text,
729
+ "nvidia",
730
+ 8192,
731
+ 0.4
732
+ // même garde-fou anti-répétition que convertImage ci-dessus
733
+ );
734
+ }
735
+ };
736
+
737
+ // ../core/src/ocr/openai-provider.ts
738
+ var OPENAI_OCR_MODEL = OCR_MODELS.openai;
739
+ var ENDPOINT5 = "https://api.openai.com/v1/chat/completions";
740
+ var openAiProvider = {
741
+ id: "openai",
742
+ model: OPENAI_OCR_MODEL,
743
+ async convertImage(img, mime, apiKey, signal, prompt) {
744
+ const response = await fetch(ENDPOINT5, {
745
+ method: "POST",
746
+ signal,
747
+ headers: {
748
+ "Content-Type": "application/json",
749
+ Authorization: `Bearer ${apiKey}`
750
+ },
751
+ body: JSON.stringify({
752
+ model: OPENAI_OCR_MODEL,
753
+ max_tokens: 8192,
754
+ messages: [
755
+ {
756
+ role: "user",
757
+ content: [
758
+ { type: "text", text: prompt },
759
+ { type: "image_url", image_url: { url: toDataUrl(img, mime) } }
760
+ ]
761
+ }
762
+ ]
763
+ })
764
+ });
765
+ if (!response.ok) throw httpStatusToOcrError(response.status, "openai");
766
+ const payload = await response.json();
767
+ const markdown = payload.choices?.[0]?.message?.content;
768
+ if (typeof markdown !== "string" || markdown.length === 0) {
769
+ throw new OcrError("UNKNOWN", "openai : r\xE9ponse sans contenu");
770
+ }
771
+ return { markdown, raw: payload };
772
+ },
773
+ translateText(text, apiKey, signal, prompt) {
774
+ return callOpenAiCompatibleChatText(
775
+ ENDPOINT5,
776
+ OPENAI_OCR_MODEL,
777
+ apiKey,
778
+ signal,
779
+ prompt,
780
+ text,
781
+ "openai"
782
+ );
783
+ }
784
+ };
785
+
786
+ // ../core/src/ocr/ocr-runner.ts
787
+ var OCR_TIMEOUT_MS = 12e4;
788
+ var OCR_RETRY_BACKOFF_MS = [1e3, 4e3];
789
+ var REPEATED_LINE_LIMIT = 4;
790
+ function collapseRepeatedLines(markdown) {
791
+ const lines = markdown.split("\n");
792
+ const kept = [];
793
+ let prevContent = null;
794
+ let streak = 0;
795
+ for (const line of lines) {
796
+ const content = line.replace(/^[#*\-\s]+/, "").trim();
797
+ if (content.length === 0) {
798
+ kept.push(line);
799
+ continue;
800
+ }
801
+ streak = content === prevContent ? streak + 1 : 0;
802
+ if (streak >= REPEATED_LINE_LIMIT) {
803
+ kept.push("\n*(r\xE9ponse tronqu\xE9e \u2014 r\xE9p\xE9tition d\xE9tect\xE9e)*");
804
+ break;
805
+ }
806
+ prevContent = content;
807
+ kept.push(line);
808
+ }
809
+ return kept.join("\n");
810
+ }
811
+ var REFUSAL_RE = /^(i['’]?m sorry|i cannot|i can['’]?t|i am (unable|not able)|sorry,|je suis désolé|désolé,|je ne peux pas|lo siento|mi dispiace|es tut mir leid|ich kann)/i;
812
+ function isProviderRefusal(markdown) {
813
+ return REFUSAL_RE.test(markdown.trim().slice(0, 80));
814
+ }
815
+ var OCR_PROVIDERS = {
816
+ openai: openAiProvider,
817
+ mistral: mistralProvider,
818
+ claude: claudeProvider,
819
+ nvidia: nvidiaProvider,
820
+ gemini: geminiProvider
821
+ };
822
+ var IMAGE_MIME = {
823
+ ".png": "image/png",
824
+ ".jpg": "image/jpeg",
825
+ ".jpeg": "image/jpeg",
826
+ ".webp": "image/webp",
827
+ ".gif": "image/gif",
828
+ // HEIC/TIFF normalisés en PNG avant l'appel API (les API vision les refusent)
829
+ ".tiff": "image/tiff",
830
+ ".tif": "image/tiff",
831
+ ".heic": "image/heic",
832
+ ".heif": "image/heif"
833
+ };
834
+ var ApiOcrRunner = class {
835
+ constructor(settings, options = {}) {
836
+ this.settings = settings;
837
+ this.timeoutMs = options.timeoutMs ?? OCR_TIMEOUT_MS;
838
+ this.backoffMs = options.backoffMs ?? OCR_RETRY_BACKOFF_MS;
839
+ }
840
+ timeoutMs;
841
+ backoffMs;
842
+ async convertImage(sourcePath) {
843
+ const settings = this.settings.getAppSettings();
844
+ const prompt = buildOcrPrompt(LOCALE_LANGUAGE[settings.locale]);
845
+ const ext = (0, import_node_path.extname)(sourcePath).toLowerCase();
846
+ const mime = IMAGE_MIME[ext];
847
+ if (!mime) {
848
+ throw new ConversionError("UNSUPPORTED_FORMAT", `Image non prise en charge : ${sourcePath}`);
849
+ }
850
+ let img = await (0, import_promises.readFile)(sourcePath);
851
+ let sendMime = mime;
852
+ if (needsPngNormalization(ext)) {
853
+ img = await toPngBuffer(img);
854
+ sendMime = "image/png";
855
+ }
856
+ if (settings.customProviderId) {
857
+ const customs = this.settings.getCustomProviders();
858
+ const custom = customs.find((p) => p.id === settings.customProviderId);
859
+ if (!custom) {
860
+ throw new ConversionError(
861
+ "UNKNOWN",
862
+ `Provider personnalis\xE9 introuvable : ${settings.customProviderId}`
863
+ );
864
+ }
865
+ const apiKey2 = this.settings.getCustomApiKey(settings.customProviderId);
866
+ if (!apiKey2) {
867
+ throw new ConversionError("API_KEY_MISSING", `Aucune cl\xE9 API pour ${custom.name}`);
868
+ }
869
+ const result2 = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
870
+ if (isProviderRefusal(result2.markdown)) {
871
+ throw new ConversionError("OCR_REFUSED", `${custom.name} : refus du mod\xE8le`);
872
+ }
873
+ return {
874
+ markdown: collapseRepeatedLines(result2.markdown),
875
+ meta: { provider: custom.name, model: custom.model }
876
+ };
877
+ }
878
+ const providerId = settings.ocrProvider;
879
+ const provider = OCR_PROVIDERS[providerId];
880
+ const apiKey = this.settings.getApiKey(providerId);
881
+ if (!apiKey) {
882
+ throw new ConversionError(
883
+ "API_KEY_MISSING",
884
+ `Aucune cl\xE9 API enregistr\xE9e pour le provider ${providerId}`
885
+ );
886
+ }
887
+ const result = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
888
+ if (isProviderRefusal(result.markdown)) {
889
+ throw new ConversionError("OCR_REFUSED", `${provider.id} : refus du mod\xE8le`);
890
+ }
891
+ return {
892
+ markdown: collapseRepeatedLines(result.markdown),
893
+ meta: { provider: provider.id, model: provider.model }
894
+ };
895
+ }
896
+ /** Tentative initiale + retries backoff sur erreurs retryable uniquement */
897
+ async callWithRetry(provider, img, mime, apiKey, prompt) {
898
+ let lastError = null;
899
+ for (let attempt = 0; attempt <= this.backoffMs.length; attempt++) {
900
+ if (attempt > 0) await sleep(this.backoffMs[attempt - 1]);
901
+ try {
902
+ return await this.singleAttempt(provider, img, mime, apiKey, prompt);
903
+ } catch (err) {
904
+ const converted = toConversionError(err, provider.id);
905
+ const retryable = err instanceof OcrError && err.retryable;
906
+ if (!retryable) throw converted;
907
+ lastError = converted;
908
+ }
909
+ }
910
+ throw lastError ?? new ConversionError("UNKNOWN", `${provider.id} : \xE9chec OCR`);
911
+ }
912
+ async singleAttempt(provider, img, mime, apiKey, prompt) {
913
+ const controller = new AbortController();
914
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
915
+ try {
916
+ return await provider.convertImage(img, mime, apiKey, controller.signal, prompt);
917
+ } catch (err) {
918
+ if (controller.signal.aborted) {
919
+ throw new ConversionError("TIMEOUT", `${provider.id} : d\xE9lai OCR d\xE9pass\xE9`);
920
+ }
921
+ if (!(err instanceof OcrError) && !(err instanceof ConversionError)) {
922
+ throw new OcrError("NETWORK", `${provider.id} : erreur r\xE9seau`, true);
923
+ }
924
+ throw err;
925
+ } finally {
926
+ clearTimeout(timer);
927
+ }
928
+ }
929
+ async callCustomWithRetry(custom, img, mime, apiKey, prompt) {
930
+ let lastError = null;
931
+ for (let attempt = 0; attempt <= this.backoffMs.length; attempt++) {
932
+ if (attempt > 0) await sleep(this.backoffMs[attempt - 1]);
933
+ try {
934
+ const controller = new AbortController();
935
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
936
+ try {
937
+ return await callCustomProvider(custom, img, mime, apiKey, controller.signal, prompt);
938
+ } catch (err) {
939
+ if (controller.signal.aborted) {
940
+ throw new ConversionError("TIMEOUT", `${custom.name} : d\xE9lai OCR d\xE9pass\xE9`);
941
+ }
942
+ if (!(err instanceof OcrError) && !(err instanceof ConversionError)) {
943
+ throw new OcrError("NETWORK", `${custom.name} : erreur r\xE9seau`, true);
944
+ }
945
+ throw err;
946
+ } finally {
947
+ clearTimeout(timer);
948
+ }
949
+ } catch (err) {
950
+ const converted = toConversionErrorStr(err, custom.name);
951
+ const retryable = err instanceof OcrError && err.retryable;
952
+ if (!retryable) throw converted;
953
+ lastError = converted;
954
+ }
955
+ }
956
+ throw lastError ?? new ConversionError("UNKNOWN", `${custom.name} : \xE9chec OCR`);
957
+ }
958
+ };
959
+ function toConversionErrorStr(err, name) {
960
+ if (err instanceof ConversionError) return err;
961
+ if (err instanceof OcrError) return new ConversionError(err.code, err.message);
962
+ return new ConversionError("UNKNOWN", `${name} : \xE9chec OCR`);
963
+ }
964
+ function toConversionError(err, providerId) {
965
+ if (err instanceof ConversionError) return err;
966
+ if (err instanceof OcrError) return new ConversionError(err.code, err.message);
967
+ return new ConversionError("UNKNOWN", `${providerId} : \xE9chec OCR`);
968
+ }
969
+ function sleep(ms) {
970
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
971
+ }
972
+
973
+ // ../core/src/ocr/locale-detect.ts
974
+ var STOPWORDS = {
975
+ fr: [
976
+ "le",
977
+ "la",
978
+ "les",
979
+ "de",
980
+ "des",
981
+ "du",
982
+ "et",
983
+ "est",
984
+ "une",
985
+ "un",
986
+ "dans",
987
+ "pour",
988
+ "que",
989
+ "qui",
990
+ "avec",
991
+ "sur",
992
+ "pas",
993
+ "vous",
994
+ "nous",
995
+ "mais",
996
+ "ou",
997
+ "plus",
998
+ "sont",
999
+ "ce",
1000
+ "cette",
1001
+ "au",
1002
+ "aux",
1003
+ "leur"
1004
+ ],
1005
+ en: [
1006
+ "the",
1007
+ "and",
1008
+ "is",
1009
+ "of",
1010
+ "to",
1011
+ "in",
1012
+ "that",
1013
+ "for",
1014
+ "with",
1015
+ "on",
1016
+ "are",
1017
+ "was",
1018
+ "this",
1019
+ "but",
1020
+ "not",
1021
+ "you",
1022
+ "we",
1023
+ "or",
1024
+ "have",
1025
+ "as",
1026
+ "be",
1027
+ "it",
1028
+ "from",
1029
+ "by"
1030
+ ],
1031
+ it: [
1032
+ "il",
1033
+ "la",
1034
+ "di",
1035
+ "che",
1036
+ "e",
1037
+ "un",
1038
+ "una",
1039
+ "per",
1040
+ "con",
1041
+ "non",
1042
+ "sono",
1043
+ "gli",
1044
+ "le",
1045
+ "del",
1046
+ "della",
1047
+ "ma",
1048
+ "anche",
1049
+ "come",
1050
+ "pi\xF9",
1051
+ "questo",
1052
+ "questa",
1053
+ "sono"
1054
+ ],
1055
+ de: [
1056
+ "der",
1057
+ "die",
1058
+ "das",
1059
+ "und",
1060
+ "ist",
1061
+ "ein",
1062
+ "eine",
1063
+ "mit",
1064
+ "f\xFCr",
1065
+ "auf",
1066
+ "nicht",
1067
+ "sie",
1068
+ "wir",
1069
+ "aber",
1070
+ "oder",
1071
+ "von",
1072
+ "zu",
1073
+ "im",
1074
+ "sind",
1075
+ "auch"
1076
+ ],
1077
+ es: [
1078
+ "el",
1079
+ "la",
1080
+ "los",
1081
+ "las",
1082
+ "de",
1083
+ "y",
1084
+ "es",
1085
+ "un",
1086
+ "una",
1087
+ "para",
1088
+ "con",
1089
+ "no",
1090
+ "son",
1091
+ "pero",
1092
+ "m\xE1s",
1093
+ "como",
1094
+ "este",
1095
+ "esta",
1096
+ "en",
1097
+ "su"
1098
+ ]
1099
+ };
1100
+ var LOCALES = ["fr", "en", "it", "de", "es"];
1101
+ var MIN_SCORE = 0.03;
1102
+ var MIN_MARGIN = 1.3;
1103
+ function detectLocale(text) {
1104
+ const tokens = text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").match(/[a-z]+/g);
1105
+ if (!tokens || tokens.length < 20) return null;
1106
+ const scores = LOCALES.map((locale) => {
1107
+ const words = new Set(STOPWORDS[locale]);
1108
+ const hits = tokens.filter((t) => words.has(t)).length;
1109
+ return { locale, score: hits / tokens.length };
1110
+ }).sort((a, b) => b.score - a.score);
1111
+ const [best, second] = scores;
1112
+ if (best.score < MIN_SCORE) return null;
1113
+ if (second && best.score < second.score * MIN_MARGIN) return null;
1114
+ return best.locale;
1115
+ }
1116
+
1117
+ // ../core/src/ocr/markdown-chunker.ts
1118
+ var CHUNK_CHAR_BUDGET = 6e3;
1119
+ function splitMarkdownIntoChunks(markdown, budget = CHUNK_CHAR_BUDGET) {
1120
+ if (markdown.length <= budget) return [markdown];
1121
+ const blocks = markdown.split(/\n{2,}/);
1122
+ const chunks = [];
1123
+ let current = "";
1124
+ for (const block of blocks) {
1125
+ const candidate = current ? `${current}
1126
+
1127
+ ${block}` : block;
1128
+ if (candidate.length > budget && current) {
1129
+ chunks.push(current);
1130
+ current = block;
1131
+ } else {
1132
+ current = candidate;
1133
+ }
1134
+ }
1135
+ if (current) chunks.push(current);
1136
+ return chunks.length > 0 ? chunks : [markdown];
1137
+ }
1138
+
1139
+ // ../core/src/ocr/translation-runner.ts
1140
+ var TranslationRunner = class {
1141
+ constructor(settings, options = {}) {
1142
+ this.settings = settings;
1143
+ this.timeoutMs = options.timeoutMs ?? OCR_TIMEOUT_MS;
1144
+ this.backoffMs = options.backoffMs ?? OCR_RETRY_BACKOFF_MS;
1145
+ }
1146
+ timeoutMs;
1147
+ backoffMs;
1148
+ async translate(markdown, targetLocale) {
1149
+ if (detectLocale(markdown) === targetLocale) return markdown;
1150
+ const settings = this.settings.getAppSettings();
1151
+ const prompt = buildTranslatePrompt(targetLocale);
1152
+ let providerLabel;
1153
+ let call;
1154
+ if (settings.customProviderId) {
1155
+ const custom = this.settings.getCustomProviders().find((p) => p.id === settings.customProviderId);
1156
+ if (!custom) {
1157
+ throw new ConversionError(
1158
+ "UNKNOWN",
1159
+ `Provider personnalis\xE9 introuvable : ${settings.customProviderId}`
1160
+ );
1161
+ }
1162
+ const apiKey = this.settings.getCustomApiKey(settings.customProviderId);
1163
+ if (!apiKey) {
1164
+ throw new ConversionError("API_KEY_MISSING", `Aucune cl\xE9 API pour ${custom.name}`);
1165
+ }
1166
+ providerLabel = custom.name;
1167
+ call = (text, signal) => callCustomProviderTranslate(custom, text, apiKey, signal, prompt);
1168
+ } else {
1169
+ const providerId = settings.ocrProvider;
1170
+ const provider = OCR_PROVIDERS[providerId];
1171
+ const apiKey = this.settings.getApiKey(providerId);
1172
+ if (!apiKey) {
1173
+ throw new ConversionError(
1174
+ "API_KEY_MISSING",
1175
+ `Aucune cl\xE9 API enregistr\xE9e pour le provider ${providerId}`
1176
+ );
1177
+ }
1178
+ providerLabel = provider.id;
1179
+ call = (text, signal) => provider.translateText(text, apiKey, signal, prompt);
1180
+ }
1181
+ const chunks = splitMarkdownIntoChunks(markdown);
1182
+ const translated = [];
1183
+ for (const chunk of chunks) {
1184
+ translated.push(await this.withRetry(providerLabel, (signal) => call(chunk, signal)));
1185
+ }
1186
+ return translated.join("\n\n");
1187
+ }
1188
+ async withRetry(providerLabel, attempt) {
1189
+ let lastError = null;
1190
+ for (let i = 0; i <= this.backoffMs.length; i++) {
1191
+ if (i > 0) await sleep2(this.backoffMs[i - 1]);
1192
+ const controller = new AbortController();
1193
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1194
+ try {
1195
+ const result = await attempt(controller.signal);
1196
+ return collapseRepeatedLines(result.markdown);
1197
+ } catch (err) {
1198
+ if (controller.signal.aborted) {
1199
+ throw new ConversionError("TIMEOUT", `${providerLabel} : d\xE9lai de traduction d\xE9pass\xE9`);
1200
+ }
1201
+ const normalized = err instanceof OcrError || err instanceof ConversionError ? err : new OcrError("NETWORK", `${providerLabel} : erreur r\xE9seau`, true);
1202
+ const retryable = normalized instanceof OcrError && normalized.retryable;
1203
+ const converted = normalized instanceof ConversionError ? normalized : new ConversionError(normalized.code, normalized.message);
1204
+ if (!retryable) throw converted;
1205
+ lastError = converted;
1206
+ } finally {
1207
+ clearTimeout(timer);
1208
+ }
1209
+ }
1210
+ throw lastError ?? new ConversionError("UNKNOWN", `${providerLabel} : \xE9chec de traduction`);
1211
+ }
1212
+ };
1213
+ function sleep2(ms) {
1214
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1215
+ }
1216
+
1217
+ // ../core/src/conversion/embedded-images.ts
1218
+ var import_node_crypto2 = require("node:crypto");
1219
+ var import_promises2 = require("node:fs/promises");
1220
+ var import_node_os = require("node:os");
1221
+ var import_node_path2 = require("node:path");
1222
+ var ADVANCED_CONVERSION_EXTENSIONS = /* @__PURE__ */ new Set([
1223
+ "docx",
1224
+ "pptx",
1225
+ "html",
1226
+ "htm",
1227
+ "eml",
1228
+ "pdf",
1229
+ "xlsx"
1230
+ ]);
1231
+ var DATA_URI_IMAGE_RE = /!\[([^\]]*)\]\(data:([^;,)]+)(?:;[^,)]*)?,([A-Za-z0-9+/=]+)\)/g;
1232
+ var DEFAULT_EMBEDDED_OCR_CONCURRENCY = 3;
1233
+ function uniqueImageIndices(matches) {
1234
+ const seen = /* @__PURE__ */ new Set();
1235
+ const unique = /* @__PURE__ */ new Set();
1236
+ matches.forEach((match, i) => {
1237
+ if (!seen.has(match[3])) {
1238
+ seen.add(match[3]);
1239
+ unique.add(i);
1240
+ }
1241
+ });
1242
+ return unique;
1243
+ }
1244
+ async function ocrEmbeddedImages(result, options) {
1245
+ const matches = [...result.markdown.matchAll(DATA_URI_IMAGE_RE)];
1246
+ if (matches.length === 0) return result;
1247
+ const ocr = options.ocr;
1248
+ if (!ocr) {
1249
+ throw new ConversionError(
1250
+ "API_KEY_MISSING",
1251
+ `${matches.length} image(s) embarqu\xE9e(s) d\xE9tect\xE9e(s), aucun provider OCR configur\xE9`
1252
+ );
1253
+ }
1254
+ const unique = uniqueImageIndices(matches);
1255
+ const concurrency = options.concurrency ?? DEFAULT_EMBEDDED_OCR_CONCURRENCY;
1256
+ const replacements = new Array(matches.length);
1257
+ let succeeded = 0;
1258
+ let failed = 0;
1259
+ let deduplicated = 0;
1260
+ let nextIndex = 0;
1261
+ const worker = async () => {
1262
+ while (nextIndex < matches.length) {
1263
+ const i = nextIndex++;
1264
+ if (!unique.has(i)) {
1265
+ replacements[i] = "";
1266
+ deduplicated += 1;
1267
+ continue;
1268
+ }
1269
+ const [, alt, mimeType, base64] = matches[i];
1270
+ try {
1271
+ replacements[i] = await ocrDataUriImage(ocr, mimeType, base64, options.formatTranscript);
1272
+ succeeded += 1;
1273
+ } catch (err) {
1274
+ const detail = err instanceof Error ? err.message : String(err);
1275
+ options.onImageError?.(detail);
1276
+ replacements[i] = `![${alt || "image non convertie"}]()`;
1277
+ failed += 1;
1278
+ }
1279
+ }
1280
+ };
1281
+ await Promise.all(Array.from({ length: Math.min(concurrency, matches.length) }, worker));
1282
+ let markdown = "";
1283
+ let cursor = 0;
1284
+ matches.forEach((match, i) => {
1285
+ const start = match.index ?? 0;
1286
+ markdown += result.markdown.slice(cursor, start);
1287
+ markdown += replacements[i];
1288
+ cursor = start + match[0].length;
1289
+ });
1290
+ markdown += result.markdown.slice(cursor);
1291
+ return {
1292
+ ...result,
1293
+ markdown,
1294
+ meta: {
1295
+ ...result.meta,
1296
+ embeddedImagesOcr: succeeded,
1297
+ embeddedImagesFailed: failed,
1298
+ ...deduplicated > 0 ? { embeddedImagesDeduplicated: deduplicated } : {}
1299
+ }
1300
+ };
1301
+ }
1302
+ async function ocrDataUriImage(ocr, mimeType, base64, formatTranscript) {
1303
+ const extension = mimeType.split("/")[1]?.split("+")[0] || "png";
1304
+ const tmpPath = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `${(0, import_node_crypto2.randomUUID)()}.${extension}`);
1305
+ await (0, import_promises2.writeFile)(tmpPath, Buffer.from(base64, "base64"));
1306
+ try {
1307
+ const ocrResult = await ocr.convertImage(tmpPath);
1308
+ const text = ocrResult.markdown.trim();
1309
+ return `
1310
+
1311
+ ${formatTranscript ? formatTranscript(text, ocrResult.meta) : text}
1312
+
1313
+ `;
1314
+ } finally {
1315
+ await (0, import_promises2.unlink)(tmpPath).catch(() => {
1316
+ });
1317
+ }
1318
+ }
1319
+
1320
+ // ../core/src/conversion/pdf.ts
1321
+ var SCAN_TEXT_THRESHOLD = 20;
1322
+ function isLikelyScannedPdf(probe) {
1323
+ return probe.pageCount > 0 && probe.pagesTextLength.every((len) => len < SCAN_TEXT_THRESHOLD);
1324
+ }
1325
+ async function ocrPdfPages(options) {
1326
+ const { sidecar, ocr, sourcePath, mode } = options;
1327
+ let pageCount = options.knownPageCount ?? 0;
1328
+ if (pageCount < 1 && (mode === "all" || mode === "page")) {
1329
+ pageCount = (await sidecar.probePdf(sourcePath)).pageCount;
1330
+ }
1331
+ pageCount = Math.max(pageCount, 1);
1332
+ const pages = mode === "all" ? Array.from({ length: pageCount }, (_, i) => i + 1) : mode === "page" ? [Math.min(Math.max(1, options.page ?? 1), pageCount)] : [1];
1333
+ const parts = [];
1334
+ for (const page of pages) {
1335
+ const imagePath = await sidecar.renderPdfPage(sourcePath, page);
1336
+ const result = await ocr.convertImage(imagePath);
1337
+ parts.push(pages.length > 1 ? `## Page ${page}
1338
+
1339
+ ${result.markdown}` : result.markdown);
1340
+ }
1341
+ return { markdown: parts.join("\n\n"), meta: { ocrPages: pages } };
1342
+ }
1343
+
1344
+ // ../core/src/export.ts
1345
+ var import_promises3 = require("node:fs/promises");
1346
+ var import_node_path3 = require("node:path");
1347
+ function buildJsonEnvelope(doc) {
1348
+ return {
1349
+ id: doc.id,
1350
+ name: doc.name,
1351
+ extension: doc.extension,
1352
+ mimeType: doc.mimeType,
1353
+ sizeBytes: doc.sizeBytes,
1354
+ sourcePath: doc.sourcePath,
1355
+ pipeline: doc.pipeline,
1356
+ ocrProvider: doc.ocrProvider ?? null,
1357
+ status: doc.status,
1358
+ createdAt: doc.createdAt,
1359
+ convertedAt: doc.convertedAt ?? null,
1360
+ meta: doc.meta ?? {},
1361
+ markdown: doc.markdown ?? ""
1362
+ };
1363
+ }
1364
+ function serializeJsonExport(doc) {
1365
+ return JSON.stringify(buildJsonEnvelope(doc), null, 2);
1366
+ }
1367
+ async function resolveCollisionPath(dir, stem, ext) {
1368
+ const candidate = (n2) => (0, import_node_path3.join)(dir, n2 === 0 ? `${stem}${ext}` : `${stem} (${n2})${ext}`);
1369
+ let n = 0;
1370
+ while (n < 999) {
1371
+ try {
1372
+ await (0, import_promises3.access)(candidate(n));
1373
+ n++;
1374
+ } catch {
1375
+ return candidate(n);
1376
+ }
1377
+ }
1378
+ return candidate(n);
1379
+ }
1380
+
1381
+ // ../core/src/license.ts
1382
+ var TRIAL_DAYS = 14;
1383
+ var LS_API_BASE = "https://api.lemonsqueezy.com/v1";
1384
+ var LS_STORE_URL = "https://sygal.lemonsqueezy.com";
1385
+ var LS_STORE_ID = 419968;
1386
+ function computeLicenseStatus(data, now = Date.now()) {
1387
+ if (data.licenseKey && data.instanceId) {
1388
+ return { state: "active", licenseKey: data.licenseKey };
1389
+ }
1390
+ const firstLaunch = new Date(data.firstLaunchDate).getTime();
1391
+ const daysSince = Math.floor((now - firstLaunch) / (1e3 * 60 * 60 * 24));
1392
+ const daysLeft = Math.max(0, TRIAL_DAYS - daysSince);
1393
+ if (daysLeft > 0) return { state: "trial", daysLeft };
1394
+ return { state: "expired" };
1395
+ }
1396
+ var FORM_HEADERS = {
1397
+ Accept: "application/json",
1398
+ "Content-Type": "application/x-www-form-urlencoded"
1399
+ };
1400
+ async function parseJson(res) {
1401
+ try {
1402
+ return await res.json();
1403
+ } catch {
1404
+ return null;
1405
+ }
1406
+ }
1407
+ async function lsActivate(licenseKey, instanceName, fetchImpl = fetch) {
1408
+ const body = new URLSearchParams({ license_key: licenseKey, instance_name: instanceName });
1409
+ let res;
1410
+ try {
1411
+ res = await fetchImpl(`${LS_API_BASE}/licenses/activate`, {
1412
+ method: "POST",
1413
+ headers: FORM_HEADERS,
1414
+ body: body.toString()
1415
+ });
1416
+ } catch {
1417
+ return {
1418
+ ok: false,
1419
+ error: "Impossible de joindre le serveur de licence. V\xE9rifiez votre connexion."
1420
+ };
1421
+ }
1422
+ const json = await parseJson(res);
1423
+ if (!res.ok || !json?.activated) {
1424
+ return { ok: false, error: json?.error ?? `HTTP ${res.status}` };
1425
+ }
1426
+ if (LS_STORE_ID !== null && json.meta?.store_id !== void 0 && json.meta.store_id !== LS_STORE_ID) {
1427
+ return { ok: false, error: "Cl\xE9 \xE9mise par un autre store" };
1428
+ }
1429
+ const instanceId = json.instance?.id;
1430
+ if (!instanceId) {
1431
+ return { ok: false, error: "R\xE9ponse API invalide (instance id manquant)" };
1432
+ }
1433
+ return { ok: true, activation: { instanceId } };
1434
+ }
1435
+ async function lsValidate(licenseKey, instanceId, fetchImpl = fetch) {
1436
+ const body = new URLSearchParams({ license_key: licenseKey, instance_id: instanceId });
1437
+ try {
1438
+ const res = await fetchImpl(`${LS_API_BASE}/licenses/validate`, {
1439
+ method: "POST",
1440
+ headers: FORM_HEADERS,
1441
+ body: body.toString()
1442
+ });
1443
+ if (!res.ok) return "unreachable";
1444
+ const json = await parseJson(res);
1445
+ return json?.valid === false ? "revoked" : "valid";
1446
+ } catch {
1447
+ return "unreachable";
1448
+ }
1449
+ }
1450
+ async function lsDeactivate(licenseKey, instanceId, fetchImpl = fetch) {
1451
+ const body = new URLSearchParams({ license_key: licenseKey, instance_id: instanceId });
1452
+ try {
1453
+ const res = await fetchImpl(`${LS_API_BASE}/licenses/deactivate`, {
1454
+ method: "POST",
1455
+ headers: FORM_HEADERS,
1456
+ body: body.toString()
1457
+ });
1458
+ const json = await parseJson(res);
1459
+ if (!res.ok || !json?.deactivated) {
1460
+ return { ok: false, error: json?.error ?? `HTTP ${res.status}` };
1461
+ }
1462
+ return { ok: true };
1463
+ } catch {
1464
+ return {
1465
+ ok: false,
1466
+ error: "Impossible de joindre le serveur de licence. V\xE9rifiez votre connexion."
1467
+ };
1468
+ }
1469
+ }
1470
+
1471
+ // src/convert.ts
1472
+ var import_node_crypto3 = require("node:crypto");
1473
+ var import_node_fs2 = require("node:fs");
1474
+ var import_node_path5 = require("node:path");
1475
+
1476
+ // src/defer.ts
1477
+ var import_node_fs = require("node:fs");
1478
+ var import_node_path4 = require("node:path");
1479
+ function formatTranscriptBlock(text, provider) {
1480
+ return `---
1481
+ *Transcription image OCR (IA : ${provider}) :*
1482
+
1483
+ ${text}
1484
+
1485
+ ---`;
1486
+ }
1487
+ async function deferEmbeddedImages(markdown, assetsDir) {
1488
+ const matches = [...markdown.matchAll(DATA_URI_IMAGE_RE)];
1489
+ if (matches.length === 0) return { markdown, images: [] };
1490
+ await import_node_fs.promises.mkdir(assetsDir, { recursive: true });
1491
+ const images = [];
1492
+ const seen = /* @__PURE__ */ new Set();
1493
+ let out = "";
1494
+ let cursor = 0;
1495
+ let index = 0;
1496
+ for (const match of matches) {
1497
+ const [, , mimeType, base64] = match;
1498
+ const start = match.index ?? 0;
1499
+ out += markdown.slice(cursor, start);
1500
+ cursor = start + match[0].length;
1501
+ if (seen.has(base64)) continue;
1502
+ seen.add(base64);
1503
+ index += 1;
1504
+ const extension = mimeType.split("/")[1]?.split("+")[0] || "png";
1505
+ const path = (0, import_node_path4.join)(assetsDir, `img-${index}.${extension}`);
1506
+ await import_node_fs.promises.writeFile(path, Buffer.from(base64, "base64"));
1507
+ images.push({ index, path, mimeType });
1508
+ out += placeholder(index, (0, import_node_path4.relative)((0, import_node_path4.dirname)(assetsDir), path));
1509
+ }
1510
+ out += markdown.slice(cursor);
1511
+ return { markdown: out, images };
1512
+ }
1513
+ function placeholder(index, relPath) {
1514
+ return `![sygal:ocr:${index}](${relPath})`;
1515
+ }
1516
+ var PLACEHOLDER_RE = /!\[sygal:ocr:(\d+)\]\(([^)]*)\)/g;
1517
+ async function injectTranscripts(markdownPath, transcripts) {
1518
+ const markdown = await import_node_fs.promises.readFile(markdownPath, "utf-8");
1519
+ const byIndex = /* @__PURE__ */ new Map();
1520
+ for (const t of transcripts) byIndex.set(t.index, t);
1521
+ let injected = 0;
1522
+ let remaining = 0;
1523
+ const out = markdown.replace(PLACEHOLDER_RE, (whole, rawIndex) => {
1524
+ const transcript = byIndex.get(Number(rawIndex));
1525
+ const text = transcript?.text ?? transcript?.ocrText;
1526
+ if (!transcript || typeof text !== "string") {
1527
+ remaining += 1;
1528
+ return whole;
1529
+ }
1530
+ injected += 1;
1531
+ const provider = transcript.provider ?? transcript.ocrProvider ?? "externe";
1532
+ return formatTranscriptBlock(text.trim(), provider);
1533
+ });
1534
+ await import_node_fs.promises.writeFile(markdownPath, out, "utf-8");
1535
+ return { injected, remaining };
1536
+ }
1537
+ function assetsDirFor(outputDir, docName) {
1538
+ return (0, import_node_path4.join)(outputDir, `${(0, import_node_path4.basename)(docName)}.images`);
1539
+ }
1540
+
1541
+ // src/errors.ts
1542
+ var CONVERSION_CODE_MAP = {
1543
+ SIDECAR_DOWN: "sidecar_down",
1544
+ TIMEOUT: "timeout",
1545
+ UNSUPPORTED_FORMAT: "unsupported_format",
1546
+ API_KEY_MISSING: "api_key_missing",
1547
+ API_KEY_INVALID: "invalid_api_key",
1548
+ RATE_LIMITED: "rate_limited",
1549
+ NETWORK: "network_error",
1550
+ OCR_REFUSED: "ocr_refused",
1551
+ URL_FORBIDDEN: "url_forbidden",
1552
+ URL_UNREACHABLE: "url_unreachable",
1553
+ UNKNOWN: "unknown"
1554
+ };
1555
+ var CliCodedError = class extends Error {
1556
+ constructor(code, message) {
1557
+ super(message);
1558
+ this.code = code;
1559
+ this.name = "CliCodedError";
1560
+ }
1561
+ };
1562
+ function toCliError(err) {
1563
+ if (err instanceof CliCodedError) {
1564
+ return { code: err.code, message: err.message };
1565
+ }
1566
+ if (err instanceof ConversionError) {
1567
+ return { code: CONVERSION_CODE_MAP[err.code] ?? "unknown", message: err.message };
1568
+ }
1569
+ if (err instanceof SidecarError) {
1570
+ switch (err.code) {
1571
+ case "NOT_FOUND":
1572
+ return { code: "file_unreadable", message: err.message };
1573
+ case "UNSUPPORTED":
1574
+ return { code: "unsupported_format", message: err.message };
1575
+ case "TIMEOUT":
1576
+ return { code: "timeout", message: err.message };
1577
+ case "SIDECAR_DOWN":
1578
+ return { code: "sidecar_down", message: err.message };
1579
+ case "URL_FORBIDDEN":
1580
+ return { code: "url_forbidden", message: err.message };
1581
+ case "URL_UNREACHABLE":
1582
+ return { code: "url_unreachable", message: err.message };
1583
+ default:
1584
+ return { code: "conversion_failed", message: err.message };
1585
+ }
1586
+ }
1587
+ return { code: "unknown", message: err instanceof Error ? err.message : String(err) };
1588
+ }
1589
+
1590
+ // src/convert.ts
1591
+ var MARKITDOWN_CONCURRENCY = 2;
1592
+ var OCR_CONCURRENCY = 3;
1593
+ var OcrBudget = class {
1594
+ constructor(limit) {
1595
+ this.limit = limit;
1596
+ }
1597
+ used = 0;
1598
+ take(count) {
1599
+ if (this.limit !== null && this.used + count > this.limit) {
1600
+ throw new CliCodedError(
1601
+ "ocr_call_limit_exceeded",
1602
+ `Budget OCR d\xE9pass\xE9 : ${count} appel(s) demand\xE9(s), ${this.limit - this.used} restant(s) sur ${this.limit} (--max-ocr-calls ou sygal config set-max-ocr-calls)`
1603
+ );
1604
+ }
1605
+ this.used += count;
1606
+ }
1607
+ get total() {
1608
+ return this.used;
1609
+ }
1610
+ };
1611
+ async function runConvert(inputs, options, deps, hooks) {
1612
+ const jobs = await expandInputs(inputs);
1613
+ hooks?.onExpanded?.(jobs.length);
1614
+ const budget = new OcrBudget(options.maxOcrCalls);
1615
+ const reports = new Array(jobs.length);
1616
+ let nextIndex = 0;
1617
+ const worker = async () => {
1618
+ while (nextIndex < jobs.length) {
1619
+ const i = nextIndex++;
1620
+ const startedAt = Date.now();
1621
+ reports[i] = await convertOne(jobs[i], options, deps, budget);
1622
+ reports[i].durationMs = Date.now() - startedAt;
1623
+ hooks?.onReport?.(reports[i]);
1624
+ }
1625
+ };
1626
+ await Promise.all(Array.from({ length: Math.min(MARKITDOWN_CONCURRENCY, jobs.length) }, worker));
1627
+ const done = reports.filter((r) => r.status === "done").length;
1628
+ return { reports, done, failed: reports.length - done, ocrCalls: budget.total };
1629
+ }
1630
+ async function expandInputs(inputs) {
1631
+ const jobs = [];
1632
+ for (const raw of inputs) {
1633
+ if (/^https?:\/\//i.test(raw.trim())) {
1634
+ jobs.push({ input: raw.trim(), kind: "url" });
1635
+ continue;
1636
+ }
1637
+ const path = (0, import_node_path5.resolve)((0, import_node_path5.normalize)(raw));
1638
+ let stat;
1639
+ try {
1640
+ stat = await import_node_fs2.promises.stat(path);
1641
+ } catch {
1642
+ jobs.push({
1643
+ input: raw,
1644
+ kind: "invalid",
1645
+ error: { code: "file_unreadable", message: `Fichier illisible ou introuvable : ${raw}` }
1646
+ });
1647
+ continue;
1648
+ }
1649
+ if (stat.isDirectory()) {
1650
+ await collectFromDir(path, jobs);
1651
+ } else {
1652
+ jobs.push({ input: path, kind: "file" });
1653
+ }
1654
+ }
1655
+ return jobs;
1656
+ }
1657
+ async function collectFromDir(dir, jobs) {
1658
+ let entries;
1659
+ try {
1660
+ entries = await import_node_fs2.promises.readdir(dir, { withFileTypes: true });
1661
+ } catch {
1662
+ return;
1663
+ }
1664
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1665
+ for (const entry of entries) {
1666
+ if (entry.name.startsWith(".")) continue;
1667
+ const full = (0, import_node_path5.join)(dir, entry.name);
1668
+ if (entry.isDirectory()) {
1669
+ await collectFromDir(full, jobs);
1670
+ } else if (entry.isFile() && mimeForExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
1671
+ jobs.push({ input: full, kind: "file" });
1672
+ }
1673
+ }
1674
+ }
1675
+ async function convertOne(job, options, deps, budget) {
1676
+ if (job.kind === "invalid") {
1677
+ return {
1678
+ input: job.input,
1679
+ name: (0, import_node_path5.basename)(job.input),
1680
+ pipeline: null,
1681
+ status: "error",
1682
+ outputs: [],
1683
+ error: job.error
1684
+ };
1685
+ }
1686
+ try {
1687
+ return job.kind === "url" ? await convertUrl(job.input, options, deps) : await convertFile(job.input, options, deps, budget);
1688
+ } catch (err) {
1689
+ return {
1690
+ input: job.input,
1691
+ name: job.kind === "url" ? job.input : (0, import_node_path5.basename)(job.input),
1692
+ pipeline: job.kind === "url" ? "markitdown" : null,
1693
+ status: "error",
1694
+ outputs: [],
1695
+ error: toCliError(err)
1696
+ };
1697
+ }
1698
+ }
1699
+ function requireOcr(deps) {
1700
+ if (!deps.ocr) {
1701
+ throw new ConversionError(
1702
+ "API_KEY_MISSING",
1703
+ `Aucune cl\xE9 API pour le provider ${deps.ocrProvider} (sygal config set-api-key ${deps.ocrProvider} ...)`
1704
+ );
1705
+ }
1706
+ return deps.ocr;
1707
+ }
1708
+ async function convertFile(path, options, deps, budget) {
1709
+ const name = (0, import_node_path5.basename)(path);
1710
+ const extension = (0, import_node_path5.extname)(path).slice(1).toLowerCase();
1711
+ const mimeType = mimeForExtension(extension);
1712
+ if (!mimeType) {
1713
+ return errorReport(path, name, null, {
1714
+ code: "unsupported_extension",
1715
+ message: `Extension non support\xE9e : .${extension || "?"}`
1716
+ });
1717
+ }
1718
+ const stat = await import_node_fs2.promises.stat(path);
1719
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
1720
+ return errorReport(path, name, null, {
1721
+ code: "file_too_large",
1722
+ message: `Fichier trop volumineux (max 200 Mo) : ${path}`
1723
+ });
1724
+ }
1725
+ const outputDir = options.output ? (0, import_node_path5.resolve)(options.output) : (0, import_node_path5.dirname)(path);
1726
+ const base = {
1727
+ input: path,
1728
+ name,
1729
+ extension,
1730
+ mimeType,
1731
+ sizeBytes: stat.size,
1732
+ sourcePath: path,
1733
+ outputDir,
1734
+ format: options.format
1735
+ };
1736
+ const pipeline = pipelineForMime(mimeType);
1737
+ if (pipeline === "ocr") {
1738
+ if (options.ocrMode === "defer") {
1739
+ const markdown = placeholder(1, name);
1740
+ return writeOutputs({
1741
+ ...base,
1742
+ pipeline,
1743
+ result: { markdown, meta: { ocrDeferred: 1 } },
1744
+ deferred: [{ index: 1, path, mimeType }]
1745
+ });
1746
+ }
1747
+ const ocr = requireOcr(deps);
1748
+ budget.take(1);
1749
+ const result2 = await ocr.convertImage(path);
1750
+ return writeOutputs({
1751
+ ...base,
1752
+ pipeline,
1753
+ result: { markdown: result2.markdown, meta: result2.meta },
1754
+ ocrProvider: deps.ocrProvider,
1755
+ ocrCalls: 1,
1756
+ translate: options.translate,
1757
+ translator: deps.translate
1758
+ });
1759
+ }
1760
+ if (extension === "pdf") {
1761
+ if (options.pdfOcr) {
1762
+ return convertPdfPages(path, options, deps, budget, base);
1763
+ }
1764
+ const probe = await deps.sidecar.probePdf(path);
1765
+ if (isLikelyScannedPdf(probe)) {
1766
+ return errorReport(path, name, "markitdown", {
1767
+ code: "scanned_pdf_detected",
1768
+ message: `PDF scann\xE9 d\xE9tect\xE9 (${probe.pageCount} page(s), pas de texte extractible) : relancez avec --pdf-ocr all|first|page [--page N]`
1769
+ });
1770
+ }
1771
+ }
1772
+ const advanced = options.advanced && ADVANCED_CONVERSION_EXTENSIONS.has(extension);
1773
+ const result = await deps.sidecar.convert(path, advanced ? { advanced: true } : {});
1774
+ if (!advanced) {
1775
+ return writeOutputs({
1776
+ ...base,
1777
+ pipeline,
1778
+ result,
1779
+ translate: options.translate,
1780
+ translator: deps.translate
1781
+ });
1782
+ }
1783
+ if (options.ocrMode === "defer") {
1784
+ const { markdown, images } = await deferEmbeddedImages(
1785
+ result.markdown,
1786
+ assetsDirFor(outputDir, name)
1787
+ );
1788
+ return writeOutputs({
1789
+ ...base,
1790
+ pipeline,
1791
+ result: { ...result, markdown, meta: { ...result.meta, ocrDeferred: images.length } },
1792
+ deferred: images
1793
+ });
1794
+ }
1795
+ const embeddedCount = uniqueImageIndices([...result.markdown.matchAll(DATA_URI_IMAGE_RE)]).size;
1796
+ if (embeddedCount > 0) {
1797
+ requireOcr(deps);
1798
+ budget.take(embeddedCount);
1799
+ }
1800
+ const converted = await ocrEmbeddedImages(result, {
1801
+ ocr: deps.ocr,
1802
+ concurrency: OCR_CONCURRENCY,
1803
+ formatTranscript: (text, meta) => formatTranscriptBlock(
1804
+ text,
1805
+ typeof meta.provider === "string" ? meta.provider : deps.ocrProvider
1806
+ )
1807
+ });
1808
+ return writeOutputs({
1809
+ ...base,
1810
+ pipeline,
1811
+ result: converted,
1812
+ ocrProvider: embeddedCount > 0 ? deps.ocrProvider : void 0,
1813
+ ocrCalls: embeddedCount,
1814
+ translate: options.translate,
1815
+ translator: deps.translate
1816
+ });
1817
+ }
1818
+ async function convertPdfPages(path, options, deps, budget, base) {
1819
+ const mode = options.pdfOcr;
1820
+ const pageCount = Math.max((await deps.sidecar.probePdf(path)).pageCount, 1);
1821
+ const pages = mode === "all" ? Array.from({ length: pageCount }, (_, i) => i + 1) : mode === "page" ? [Math.min(Math.max(1, options.pdfPage ?? 1), pageCount)] : [1];
1822
+ if (options.ocrMode === "defer") {
1823
+ const assetsDir = assetsDirFor(base.outputDir, base.name);
1824
+ await import_node_fs2.promises.mkdir(assetsDir, { recursive: true });
1825
+ const deferred = [];
1826
+ const parts = [];
1827
+ for (let i = 0; i < pages.length; i++) {
1828
+ const page = pages[i];
1829
+ const rendered = await deps.sidecar.renderPdfPage(path, page);
1830
+ const dest = (0, import_node_path5.join)(assetsDir, `img-${i + 1}.png`);
1831
+ await import_node_fs2.promises.copyFile(rendered, dest);
1832
+ deferred.push({ index: i + 1, path: dest, mimeType: "image/png", page });
1833
+ const mark = placeholder(i + 1, (0, import_node_path5.join)((0, import_node_path5.basename)(assetsDir), `img-${i + 1}.png`));
1834
+ parts.push(pages.length > 1 ? `## Page ${page}
1835
+
1836
+ ${mark}` : mark);
1837
+ }
1838
+ return writeOutputs({
1839
+ ...base,
1840
+ pipeline: "ocr",
1841
+ result: {
1842
+ markdown: parts.join("\n\n"),
1843
+ meta: { ocrPages: pages, ocrDeferred: deferred.length }
1844
+ },
1845
+ deferred
1846
+ });
1847
+ }
1848
+ const ocr = requireOcr(deps);
1849
+ budget.take(pages.length);
1850
+ const result = await ocrPdfPages({
1851
+ sidecar: deps.sidecar,
1852
+ ocr,
1853
+ sourcePath: path,
1854
+ mode,
1855
+ page: options.pdfPage,
1856
+ knownPageCount: pageCount
1857
+ });
1858
+ return writeOutputs({
1859
+ ...base,
1860
+ pipeline: "ocr",
1861
+ result,
1862
+ ocrProvider: deps.ocrProvider,
1863
+ ocrCalls: pages.length,
1864
+ translate: options.translate,
1865
+ translator: deps.translate
1866
+ });
1867
+ }
1868
+ async function convertUrl(rawUrl, options, deps) {
1869
+ let parsed;
1870
+ try {
1871
+ parsed = new URL(rawUrl);
1872
+ } catch {
1873
+ return errorReport(rawUrl, rawUrl, null, { code: "invalid_url", message: "URL invalide" });
1874
+ }
1875
+ const result = await deps.sidecar.convertUrl(parsed.toString());
1876
+ const provisional = (parsed.hostname + parsed.pathname).replace(/\/$/, "") || parsed.hostname;
1877
+ const title = typeof result.meta.title === "string" && result.meta.title.trim();
1878
+ const name = title || provisional;
1879
+ const outputDir = options.output ? (0, import_node_path5.resolve)(options.output) : process.cwd();
1880
+ return writeOutputs({
1881
+ input: rawUrl,
1882
+ name,
1883
+ extension: "url",
1884
+ mimeType: "text/html",
1885
+ sizeBytes: 0,
1886
+ sourcePath: null,
1887
+ pipeline: "markitdown",
1888
+ result,
1889
+ outputDir,
1890
+ format: options.format,
1891
+ translate: options.translate,
1892
+ translator: deps.translate
1893
+ });
1894
+ }
1895
+ async function writeOutputs(params) {
1896
+ const { result, outputDir, format } = params;
1897
+ await import_node_fs2.promises.mkdir(outputDir, { recursive: true });
1898
+ let markdown = result.markdown;
1899
+ let translatedTo;
1900
+ if (params.translate && params.translator) {
1901
+ markdown = await params.translator(markdown, params.translate);
1902
+ translatedTo = params.translate;
1903
+ }
1904
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1905
+ const envelope = {
1906
+ id: (0, import_node_crypto3.randomUUID)(),
1907
+ name: params.name,
1908
+ extension: params.extension,
1909
+ mimeType: params.mimeType,
1910
+ sizeBytes: params.sizeBytes,
1911
+ sourcePath: params.sourcePath,
1912
+ pipeline: params.pipeline,
1913
+ ...params.ocrProvider ? { ocrProvider: params.ocrProvider } : {},
1914
+ status: "done",
1915
+ createdAt: now,
1916
+ convertedAt: now,
1917
+ meta: result.meta,
1918
+ markdown
1919
+ };
1920
+ const json = serializeJsonExport(envelope);
1921
+ const outputs = [];
1922
+ if (format === "md" || format === "both") {
1923
+ const path = await resolveCollisionPath(outputDir, params.name, ".md");
1924
+ await import_node_fs2.promises.writeFile(path, markdown, "utf-8");
1925
+ outputs.push(path);
1926
+ }
1927
+ if (format === "json" || format === "both") {
1928
+ const path = await resolveCollisionPath(outputDir, params.name, ".json");
1929
+ await import_node_fs2.promises.writeFile(path, json, "utf-8");
1930
+ outputs.push(path);
1931
+ }
1932
+ return {
1933
+ input: params.input,
1934
+ name: params.name,
1935
+ pipeline: params.pipeline,
1936
+ status: "done",
1937
+ outputs,
1938
+ sizeBytes: params.sizeBytes,
1939
+ mdTokens: countTokens(markdown),
1940
+ jsonTokens: countTokens(json),
1941
+ ...params.ocrProvider ? { ocrProvider: params.ocrProvider } : {},
1942
+ ...params.ocrCalls ? { ocrCalls: params.ocrCalls } : {},
1943
+ ...params.deferred && params.deferred.length > 0 ? { deferred: params.deferred } : {},
1944
+ ...translatedTo ? { translatedTo } : {}
1945
+ };
1946
+ }
1947
+ function errorReport(input, name, pipeline, error) {
1948
+ return { input, name, pipeline, status: "error", outputs: [], error };
1949
+ }
1950
+
1951
+ // src/license.ts
1952
+ var import_node_fs5 = require("node:fs");
1953
+ var import_node_os4 = require("node:os");
1954
+ var import_node_path7 = require("node:path");
1955
+
1956
+ // src/machine-crypto.ts
1957
+ var import_node_crypto4 = require("node:crypto");
1958
+ var import_node_child_process2 = require("node:child_process");
1959
+ var import_node_fs3 = require("node:fs");
1960
+ var import_node_os2 = require("node:os");
1961
+ var ALGO = "aes-256-gcm";
1962
+ var SCRYPT_SALT = "sygal-cli-v1";
1963
+ var IV_LENGTH = 12;
1964
+ var cachedKey = null;
1965
+ function machineId() {
1966
+ try {
1967
+ if (process.platform === "darwin") {
1968
+ const out = (0, import_node_child_process2.execSync)("ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf-8" });
1969
+ const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
1970
+ if (match) return match[1];
1971
+ } else if (process.platform === "win32") {
1972
+ const out = (0, import_node_child_process2.execSync)("reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", {
1973
+ encoding: "utf-8"
1974
+ });
1975
+ const match = out.match(/MachineGuid\s+REG_SZ\s+(\S+)/);
1976
+ if (match) return match[1];
1977
+ } else {
1978
+ return (0, import_node_fs3.readFileSync)("/etc/machine-id", "utf-8").trim();
1979
+ }
1980
+ } catch {
1981
+ }
1982
+ return `${(0, import_node_os2.hostname)()}|${(0, import_node_os2.homedir)()}`;
1983
+ }
1984
+ function deriveKey() {
1985
+ if (!cachedKey) {
1986
+ cachedKey = (0, import_node_crypto4.scryptSync)(machineId(), SCRYPT_SALT, 32);
1987
+ }
1988
+ return cachedKey;
1989
+ }
1990
+ function encryptString(plain) {
1991
+ const iv = (0, import_node_crypto4.randomBytes)(IV_LENGTH);
1992
+ const cipher = (0, import_node_crypto4.createCipheriv)(ALGO, deriveKey(), iv);
1993
+ const encrypted = Buffer.concat([cipher.update(plain, "utf-8"), cipher.final()]);
1994
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64");
1995
+ }
1996
+ function decryptString(blob) {
1997
+ const raw = Buffer.from(blob, "base64");
1998
+ const iv = raw.subarray(0, IV_LENGTH);
1999
+ const tag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
2000
+ const data = raw.subarray(IV_LENGTH + 16);
2001
+ const decipher = (0, import_node_crypto4.createDecipheriv)(ALGO, deriveKey(), iv);
2002
+ decipher.setAuthTag(tag);
2003
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf-8");
2004
+ }
2005
+
2006
+ // src/settings.ts
2007
+ var import_node_fs4 = require("node:fs");
2008
+ var import_node_os3 = require("node:os");
2009
+ var import_node_path6 = require("node:path");
2010
+ var OCR_PROVIDERS_IDS = [
2011
+ "openai",
2012
+ "mistral",
2013
+ "claude",
2014
+ "nvidia",
2015
+ "gemini"
2016
+ ];
2017
+ var LOCALES2 = ["fr", "en", "it", "de", "es"];
2018
+ function configPath() {
2019
+ return process.env.SYGAL_CONFIG_PATH ?? (0, import_node_path6.join)((0, import_node_os3.homedir)(), ".sygal", "config.json");
2020
+ }
2021
+ function load() {
2022
+ const path = configPath();
2023
+ if (!(0, import_node_fs4.existsSync)(path)) return {};
2024
+ try {
2025
+ return JSON.parse((0, import_node_fs4.readFileSync)(path, "utf-8"));
2026
+ } catch {
2027
+ return {};
2028
+ }
2029
+ }
2030
+ function save(config2) {
2031
+ const path = configPath();
2032
+ (0, import_node_fs4.mkdirSync)((0, import_node_path6.dirname)(path), { recursive: true });
2033
+ (0, import_node_fs4.writeFileSync)(path, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
2034
+ try {
2035
+ (0, import_node_fs4.chmodSync)(path, 384);
2036
+ } catch {
2037
+ }
2038
+ }
2039
+ var CliSettings = class {
2040
+ config;
2041
+ constructor() {
2042
+ this.config = load();
2043
+ }
2044
+ getAppSettings() {
2045
+ const envProvider = process.env.SYGAL_OCR_PROVIDER;
2046
+ return {
2047
+ locale: this.config.locale ?? "fr",
2048
+ ocrProvider: envProvider && OCR_PROVIDERS_IDS.includes(envProvider) ? envProvider : this.config.ocrProvider ?? "openai",
2049
+ // Providers personnalisés : non supportés dans le CLI en Phase 3
2050
+ customProviderId: null
2051
+ };
2052
+ }
2053
+ getCustomProviders() {
2054
+ return [];
2055
+ }
2056
+ getCustomApiKey() {
2057
+ return null;
2058
+ }
2059
+ /** Env key applies to the ACTIVE provider only (spec §3.5) */
2060
+ getApiKey(provider) {
2061
+ const envKey = process.env.SYGAL_OCR_API_KEY;
2062
+ if (envKey && provider === this.getAppSettings().ocrProvider) return envKey;
2063
+ const stored = this.config.apiKeys?.[provider];
2064
+ if (!stored) return null;
2065
+ try {
2066
+ return decryptString(stored);
2067
+ } catch {
2068
+ return null;
2069
+ }
2070
+ }
2071
+ hasApiKey(provider) {
2072
+ return this.getApiKey(provider) !== null;
2073
+ }
2074
+ isAdvancedConversionEnabled() {
2075
+ return this.config.advancedConversion === true;
2076
+ }
2077
+ getMaxOcrCalls() {
2078
+ return typeof this.config.maxOcrCalls === "number" ? this.config.maxOcrCalls : null;
2079
+ }
2080
+ // ── Mutations (commandes `sygal config ...`) ────────────────────────────────
2081
+ setOcrProvider(provider) {
2082
+ this.config.ocrProvider = provider;
2083
+ save(this.config);
2084
+ }
2085
+ setLocale(locale) {
2086
+ this.config.locale = locale;
2087
+ save(this.config);
2088
+ }
2089
+ setAdvancedConversion(enabled) {
2090
+ this.config.advancedConversion = enabled;
2091
+ save(this.config);
2092
+ }
2093
+ setMaxOcrCalls(limit) {
2094
+ this.config.maxOcrCalls = limit;
2095
+ save(this.config);
2096
+ }
2097
+ setApiKey(provider, key) {
2098
+ this.config.apiKeys = { ...this.config.apiKeys, [provider]: encryptString(key) };
2099
+ save(this.config);
2100
+ }
2101
+ clearApiKey(provider) {
2102
+ if (this.config.apiKeys) {
2103
+ delete this.config.apiKeys[provider];
2104
+ save(this.config);
2105
+ }
2106
+ }
2107
+ /** Presence only — never values (Paramètres app parity) */
2108
+ describe() {
2109
+ const settings = this.getAppSettings();
2110
+ const apiKeys = {};
2111
+ for (const provider of OCR_PROVIDERS_IDS) {
2112
+ apiKeys[provider] = this.hasApiKey(provider);
2113
+ }
2114
+ return {
2115
+ locale: settings.locale,
2116
+ ocrProvider: settings.ocrProvider,
2117
+ advancedConversion: this.isAdvancedConversionEnabled(),
2118
+ maxOcrCalls: this.getMaxOcrCalls(),
2119
+ apiKeys
2120
+ };
2121
+ }
2122
+ };
2123
+
2124
+ // src/license.ts
2125
+ var VALIDATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
2126
+ function licensePath() {
2127
+ return process.env.SYGAL_LICENSE_PATH ?? (0, import_node_path7.join)((0, import_node_path7.dirname)(configPath()), "license.enc");
2128
+ }
2129
+ var CliLicense = class {
2130
+ data;
2131
+ constructor() {
2132
+ this.data = this.load();
2133
+ if (!this.data.firstLaunchDate) {
2134
+ this.data.firstLaunchDate = (/* @__PURE__ */ new Date()).toISOString();
2135
+ this.save();
2136
+ }
2137
+ }
2138
+ load() {
2139
+ const path = licensePath();
2140
+ if (!(0, import_node_fs5.existsSync)(path)) return {};
2141
+ try {
2142
+ return JSON.parse(decryptString((0, import_node_fs5.readFileSync)(path, "utf-8")));
2143
+ } catch {
2144
+ return {};
2145
+ }
2146
+ }
2147
+ save() {
2148
+ const path = licensePath();
2149
+ (0, import_node_fs5.mkdirSync)((0, import_node_path7.dirname)(path), { recursive: true });
2150
+ (0, import_node_fs5.writeFileSync)(path, encryptString(JSON.stringify(this.data)), { mode: 384 });
2151
+ }
2152
+ getStatus() {
2153
+ return computeLicenseStatus(this.data);
2154
+ }
2155
+ /** Env SYGAL_LICENSE_KEY : activation automatisée (agents/CI) au premier usage */
2156
+ envLicenseKey() {
2157
+ return process.env.SYGAL_LICENSE_KEY ?? null;
2158
+ }
2159
+ async activate(licenseKey) {
2160
+ const result = await lsActivate(licenseKey.trim(), `${(0, import_node_os4.hostname)()} (CLI)`);
2161
+ if (!result.ok) return result;
2162
+ this.data.licenseKey = licenseKey.trim();
2163
+ this.data.instanceId = result.activation.instanceId;
2164
+ this.data.activatedAt = (/* @__PURE__ */ new Date()).toISOString();
2165
+ this.data.lastValidatedAt = (/* @__PURE__ */ new Date()).toISOString();
2166
+ this.save();
2167
+ return { ok: true };
2168
+ }
2169
+ async deactivate() {
2170
+ if (!this.data.licenseKey || !this.data.instanceId) {
2171
+ return { ok: false, error: "Aucune licence active sur cette machine" };
2172
+ }
2173
+ const result = await lsDeactivate(this.data.licenseKey, this.data.instanceId);
2174
+ if (!result.ok) return result;
2175
+ delete this.data.licenseKey;
2176
+ delete this.data.instanceId;
2177
+ delete this.data.activatedAt;
2178
+ this.save();
2179
+ return { ok: true };
2180
+ }
2181
+ /**
2182
+ * Revalidation en ligne si la dernière date de plus d'une semaine.
2183
+ * Jamais bloquant offline ; seule une révocation explicite supprime la clé.
2184
+ */
2185
+ async validateIfStale() {
2186
+ if (!this.data.licenseKey || !this.data.instanceId) return;
2187
+ const last = this.data.lastValidatedAt ? new Date(this.data.lastValidatedAt).getTime() : 0;
2188
+ if (Date.now() - last < VALIDATION_MAX_AGE_MS) return;
2189
+ const verdict = await lsValidate(this.data.licenseKey, this.data.instanceId);
2190
+ if (verdict === "revoked") {
2191
+ delete this.data.licenseKey;
2192
+ delete this.data.instanceId;
2193
+ delete this.data.activatedAt;
2194
+ this.save();
2195
+ return;
2196
+ }
2197
+ if (verdict === "valid") {
2198
+ this.data.lastValidatedAt = (/* @__PURE__ */ new Date()).toISOString();
2199
+ this.save();
2200
+ }
2201
+ }
2202
+ /** Clé masquée pour l'affichage (jamais la valeur complète) */
2203
+ maskedKey() {
2204
+ const key = this.data.licenseKey;
2205
+ if (!key) return null;
2206
+ return key.length <= 8 ? "********" : `${"*".repeat(Math.max(4, key.length - 8))}${key.slice(-8)}`;
2207
+ }
2208
+ };
2209
+ async function requireLicensed(license) {
2210
+ let status = license.getStatus();
2211
+ if (status.state !== "active") {
2212
+ const envKey = license.envLicenseKey();
2213
+ if (envKey) {
2214
+ const result = await license.activate(envKey);
2215
+ if (result.ok) status = license.getStatus();
2216
+ }
2217
+ }
2218
+ if (status.state === "active") {
2219
+ await license.validateIfStale();
2220
+ status = license.getStatus();
2221
+ }
2222
+ if (status.state === "expired") {
2223
+ throw new CliCodedError(
2224
+ "license_expired",
2225
+ `Essai de 14 jours termin\xE9. Activez une licence : sygal license activate <cl\xE9> (achat : ${LS_STORE_URL})`
2226
+ );
2227
+ }
2228
+ }
2229
+
2230
+ // src/report.ts
2231
+ var import_picocolors2 = __toESM(require("picocolors"));
2232
+
2233
+ // src/ui.ts
2234
+ var import_picocolors = __toESM(require("picocolors"));
2235
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2236
+ var INTERVAL_MS = 80;
2237
+ var Spinner = class {
2238
+ constructor(stream = process.stderr) {
2239
+ this.stream = stream;
2240
+ this.enabled = stream.isTTY === true;
2241
+ }
2242
+ timer = null;
2243
+ frame = 0;
2244
+ text = "";
2245
+ enabled;
2246
+ start(text) {
2247
+ this.text = text;
2248
+ if (!this.enabled || this.timer) return;
2249
+ this.timer = setInterval(() => this.render(), INTERVAL_MS);
2250
+ this.render();
2251
+ }
2252
+ update(text) {
2253
+ this.text = text;
2254
+ }
2255
+ /** Prints a permanent line above the spinner without visual tearing. */
2256
+ log(line) {
2257
+ if (this.enabled && this.timer) {
2258
+ this.clear();
2259
+ console.log(line);
2260
+ this.render();
2261
+ } else {
2262
+ console.log(line);
2263
+ }
2264
+ }
2265
+ stop() {
2266
+ if (this.timer) {
2267
+ clearInterval(this.timer);
2268
+ this.timer = null;
2269
+ }
2270
+ if (this.enabled) this.clear();
2271
+ }
2272
+ render() {
2273
+ this.frame = (this.frame + 1) % FRAMES.length;
2274
+ this.stream.write(`\r\x1B[2K${import_picocolors.default.magenta(FRAMES[this.frame])} ${this.text}`);
2275
+ }
2276
+ clear() {
2277
+ this.stream.write("\r\x1B[2K");
2278
+ }
2279
+ };
2280
+ function progressBar(done, total, width = 14) {
2281
+ if (total <= 0) return "";
2282
+ const filled = Math.round(done / total * width);
2283
+ const bar = import_picocolors.default.magenta("\u25B0".repeat(filled)) + import_picocolors.default.dim("\u25B1".repeat(Math.max(0, width - filled)));
2284
+ return `${bar} ${done}/${total}`;
2285
+ }
2286
+ function formatBytes(bytes) {
2287
+ if (!bytes || bytes <= 0) return "-";
2288
+ if (bytes < 1024) return `${bytes} o`;
2289
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(".", ",")} Ko`;
2290
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} Mo`;
2291
+ }
2292
+ function renderTable(headers, rows) {
2293
+ const ansiRe = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
2294
+ const visibleLength = (s) => s.replace(ansiRe, "").length;
2295
+ const widths = headers.map(
2296
+ (h, i) => Math.max(visibleLength(h), ...rows.map((r) => visibleLength(r[i] ?? "")))
2297
+ );
2298
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - visibleLength(s)));
2299
+ const line = (cells) => cells.map((c, i) => pad(c, widths[i])).join(` ${import_picocolors.default.dim("\xB7")} `);
2300
+ const out = [import_picocolors.default.dim(line(headers).replace(/·/g, " "))];
2301
+ out.push(import_picocolors.default.dim("\u2500".repeat(widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * 5)));
2302
+ for (const row of rows) out.push(line(row));
2303
+ return out.join("\n");
2304
+ }
2305
+
2306
+ // src/report.ts
2307
+ var HumanReporter = class {
2308
+ spinner = new Spinner();
2309
+ total = 0;
2310
+ completed = 0;
2311
+ onExpanded(total) {
2312
+ this.total = total;
2313
+ this.spinner.start(total > 1 ? `Conversion ${progressBar(0, total)}` : "Conversion en cours...");
2314
+ }
2315
+ onReport(report) {
2316
+ this.completed += 1;
2317
+ this.spinner.log(fileLine(report));
2318
+ if (this.total > 1) {
2319
+ this.spinner.update(`Conversion ${progressBar(this.completed, this.total)}`);
2320
+ }
2321
+ }
2322
+ finish(summary, licenseStatus) {
2323
+ this.spinner.stop();
2324
+ if (summary.reports.length > 1) {
2325
+ const rows = summary.reports.map((r) => [
2326
+ r.name,
2327
+ formatBytes(r.sizeBytes ?? 0),
2328
+ pipelineLabel(r),
2329
+ r.status === "done" ? import_picocolors2.default.green("Termin\xE9") : import_picocolors2.default.red(`Erreur (${r.error?.code ?? "unknown"})`),
2330
+ r.mdTokens !== void 0 ? String(r.mdTokens) : "-"
2331
+ ]);
2332
+ console.log("\n" + renderTable(["Nom", "Taille", "Pipeline", "Statut", "Tokens md"], rows));
2333
+ }
2334
+ const done = summary.done > 0 ? import_picocolors2.default.green(`${summary.done} Termin\xE9(s)`) : "0 Termin\xE9";
2335
+ const failed = summary.failed > 0 ? import_picocolors2.default.red(`${summary.failed} Erreur(s)`) : import_picocolors2.default.dim("0 erreur");
2336
+ const ocr = summary.ocrCalls > 0 ? ` \xB7 ${import_picocolors2.default.magenta(`${summary.ocrCalls} appel(s) OCR`)}` : "";
2337
+ console.log(`
2338
+ ${done} \xB7 ${failed}${ocr}`);
2339
+ const deferredTotal = summary.reports.reduce((n, r) => n + (r.deferred?.length ?? 0), 0);
2340
+ if (deferredTotal > 0) {
2341
+ console.log(
2342
+ import_picocolors2.default.yellow(`${deferredTotal} image(s) \xE0 transcrire`) + import_picocolors2.default.dim(
2343
+ " \u2192 transcrivez puis r\xE9int\xE9grez avec : sygal inject <fichier.md> --transcripts <json>"
2344
+ )
2345
+ );
2346
+ }
2347
+ if (licenseStatus?.state === "trial") {
2348
+ console.log(import_picocolors2.default.dim(`Essai Sygal : ${licenseStatus.daysLeft} jour(s) restant(s)`));
2349
+ }
2350
+ }
2351
+ };
2352
+ function fileLine(report) {
2353
+ if (report.status === "done") {
2354
+ const badge = import_picocolors2.default.dim(`(${pipelineLabel(report)})`);
2355
+ const outputs = report.outputs.map((p) => shortPath(p)).join(", ");
2356
+ const deferred = report.deferred && report.deferred.length > 0 ? ` ${import_picocolors2.default.yellow(`${report.deferred.length} image(s) \xE0 transcrire`)}` : "";
2357
+ return `${import_picocolors2.default.green("\u2713")} ${report.name} ${import_picocolors2.default.magenta("\u2192")} ${outputs} ${badge}${deferred}`;
2358
+ }
2359
+ const detail = report.error ? `${report.error.message} ${import_picocolors2.default.dim(`[${report.error.code}]`)}` : "";
2360
+ return `${import_picocolors2.default.red("\u2717")} ${report.name} ${import_picocolors2.default.dim("-")} ${detail}`;
2361
+ }
2362
+ function printJson(summary, cliVersion) {
2363
+ const payload = {
2364
+ version: cliVersion,
2365
+ summary: { done: summary.done, failed: summary.failed, ocrCalls: summary.ocrCalls },
2366
+ results: summary.reports.map((r) => ({
2367
+ input: r.input,
2368
+ name: r.name,
2369
+ pipeline: r.pipeline,
2370
+ status: r.status,
2371
+ outputs: r.outputs,
2372
+ ...r.sizeBytes !== void 0 ? { sizeBytes: r.sizeBytes } : {},
2373
+ ...r.durationMs !== void 0 ? { durationMs: r.durationMs } : {},
2374
+ ...r.mdTokens !== void 0 ? { mdTokens: r.mdTokens } : {},
2375
+ ...r.jsonTokens !== void 0 ? { jsonTokens: r.jsonTokens } : {},
2376
+ ...r.ocrProvider ? { ocrProvider: r.ocrProvider } : {},
2377
+ ...r.ocrCalls ? { ocrCalls: r.ocrCalls } : {},
2378
+ ...r.deferred ? { deferred: r.deferred } : {},
2379
+ ...r.translatedTo ? { translatedTo: r.translatedTo } : {},
2380
+ ...r.error ? { error: r.error } : {}
2381
+ }))
2382
+ };
2383
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2384
+ }
2385
+ function pipelineLabel(report) {
2386
+ if (report.pipeline === null) return "-";
2387
+ if (report.pipeline !== "ocr") return "Local MarkItDown";
2388
+ return report.ocrProvider ? `API OCR (${report.ocrProvider})` : "API OCR (diff\xE9r\xE9)";
2389
+ }
2390
+ function shortPath(path) {
2391
+ const cwd = process.cwd() + "/";
2392
+ return path.startsWith(cwd) ? path.slice(cwd.length) : path;
2393
+ }
2394
+
2395
+ // src/sidecar-path.ts
2396
+ var import_node_fs6 = require("node:fs");
2397
+ var import_node_module = require("node:module");
2398
+ var import_node_path8 = require("node:path");
2399
+ var requireFromHere = (0, import_node_module.createRequire)(__filename);
2400
+ function resolveSidecarCommand() {
2401
+ const envPath = process.env.SYGAL_SIDECAR_PATH;
2402
+ if (envPath) {
2403
+ return { command: envPath, args: [] };
2404
+ }
2405
+ const suffix = process.platform === "win32" ? ".exe" : "";
2406
+ const binaryName = `markitdown-worker-${process.platform}-${process.arch}${suffix}`;
2407
+ const pkgName = `@sygal/sidecar-${process.platform}-${process.arch}`;
2408
+ try {
2409
+ const pkgJsonPath = requireFromHere.resolve(`${pkgName}/package.json`);
2410
+ const bin = (0, import_node_path8.join)((0, import_node_path8.dirname)(pkgJsonPath), "bin", binaryName);
2411
+ if ((0, import_node_fs6.existsSync)(bin)) return { command: bin, args: [] };
2412
+ } catch {
2413
+ }
2414
+ let dir = __dirname;
2415
+ for (let i = 0; i < 6; i++) {
2416
+ const converter = (0, import_node_path8.join)(dir, "python", "converter");
2417
+ if ((0, import_node_fs6.existsSync)((0, import_node_path8.join)(converter, "main.py"))) {
2418
+ const python = process.platform === "win32" ? (0, import_node_path8.join)(converter, ".venv", "Scripts", "python.exe") : (0, import_node_path8.join)(converter, ".venv", "bin", "python");
2419
+ if ((0, import_node_fs6.existsSync)(python)) {
2420
+ return { command: python, args: [(0, import_node_path8.join)(converter, "main.py")] };
2421
+ }
2422
+ const onedir = (0, import_node_path8.join)(
2423
+ converter,
2424
+ "dist",
2425
+ `markitdown-worker-${process.platform}-${process.arch}-onedir`,
2426
+ binaryName
2427
+ );
2428
+ if ((0, import_node_fs6.existsSync)(onedir)) return { command: onedir, args: [] };
2429
+ const binary = (0, import_node_path8.join)(converter, "dist", binaryName);
2430
+ if ((0, import_node_fs6.existsSync)(binary)) return { command: binary, args: [] };
2431
+ }
2432
+ const parent = (0, import_node_path8.dirname)(dir);
2433
+ if (parent === dir) break;
2434
+ dir = parent;
2435
+ }
2436
+ return null;
2437
+ }
2438
+
2439
+ // src/update.ts
2440
+ var import_node_child_process3 = require("node:child_process");
2441
+ var REGISTRY_URL = "https://registry.npmjs.org/sygal-cli/latest";
2442
+ function compareVersions(a, b) {
2443
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
2444
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
2445
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
2446
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
2447
+ if (diff !== 0) return diff;
2448
+ }
2449
+ return 0;
2450
+ }
2451
+ async function checkForUpdate(currentVersion, fetchImpl = (url) => fetch(url)) {
2452
+ try {
2453
+ const res = await fetchImpl(REGISTRY_URL);
2454
+ if (!res.ok) {
2455
+ return {
2456
+ current: currentVersion,
2457
+ latest: null,
2458
+ updateAvailable: false,
2459
+ error: res.status === 404 ? "Paquet sygal-cli introuvable sur npm (pas encore publi\xE9)" : `Registre npm : HTTP ${res.status}`
2460
+ };
2461
+ }
2462
+ const json = await res.json();
2463
+ if (!json?.version) {
2464
+ return {
2465
+ current: currentVersion,
2466
+ latest: null,
2467
+ updateAvailable: false,
2468
+ error: "R\xE9ponse du registre npm invalide"
2469
+ };
2470
+ }
2471
+ return {
2472
+ current: currentVersion,
2473
+ latest: json.version,
2474
+ updateAvailable: compareVersions(json.version, currentVersion) > 0
2475
+ };
2476
+ } catch {
2477
+ return {
2478
+ current: currentVersion,
2479
+ latest: null,
2480
+ updateAvailable: false,
2481
+ error: "Registre npm injoignable (r\xE9seau)"
2482
+ };
2483
+ }
2484
+ }
2485
+ function runGlobalInstall() {
2486
+ return new Promise((resolve2) => {
2487
+ const child = (0, import_node_child_process3.spawn)("npm", ["install", "-g", "sygal-cli@latest"], {
2488
+ stdio: "inherit",
2489
+ shell: process.platform === "win32"
2490
+ });
2491
+ child.on("close", (code) => resolve2(code ?? 1));
2492
+ child.on("error", () => resolve2(1));
2493
+ });
2494
+ }
2495
+
2496
+ // src/index.ts
2497
+ var pkg = JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path9.join)(__dirname, "..", "package.json"), "utf-8"));
2498
+ var program = new import_commander.Command();
2499
+ program.name("sygal").description(
2500
+ "Convertit fichiers et pages web en Markdown/JSON via le pipeline local MarkItDown, avec OCR API pour les images."
2501
+ ).version(pkg.version).addHelpText(
2502
+ "after",
2503
+ `
2504
+ ${import_picocolors3.default.magenta("D\xE9marrage rapide :")}
2505
+ sygal convert rapport.pdf conversion locale, .md \xE0 c\xF4t\xE9 de la source
2506
+ sygal config set-api-key openai cl\xE9 OCR (lue sur stdin, chiffr\xE9e machine)
2507
+ sygal convert capture.png image \u2192 OCR via le provider configur\xE9
2508
+ sygal license activate <cl\xE9> apr\xE8s achat (essai 14 jours inclus)
2509
+
2510
+ ${import_picocolors3.default.magenta("Pour les agents :")}
2511
+ sygal convert doc.docx --advanced --ocr defer --json extraction sans appel API
2512
+ sygal inject doc.docx.md --transcripts t.json r\xE9int\xE9gration des transcriptions
2513
+
2514
+ Documentation compl\xE8te : sygal <commande> --help`
2515
+ );
2516
+ function parseProvider(value) {
2517
+ if (!OCR_PROVIDERS_IDS.includes(value)) {
2518
+ throw new import_commander.InvalidArgumentError(`Provider inconnu (${OCR_PROVIDERS_IDS.join(" | ")})`);
2519
+ }
2520
+ return value;
2521
+ }
2522
+ function parseLocale(value) {
2523
+ if (!LOCALES2.includes(value)) {
2524
+ throw new import_commander.InvalidArgumentError(`Langue inconnue (${LOCALES2.join(" | ")})`);
2525
+ }
2526
+ return value;
2527
+ }
2528
+ function parsePositiveInt(value) {
2529
+ const n = Number(value);
2530
+ if (!Number.isInteger(n) || n < 1) throw new import_commander.InvalidArgumentError("Entier >= 1 attendu");
2531
+ return n;
2532
+ }
2533
+ program.command("convert").description("Convertit un ou plusieurs fichiers, dossiers ou URLs en Markdown/JSON").argument("<paths...>", "chemins de fichiers/dossiers ou URLs http(s)").option("-o, --output <dir>", "dossier de sortie (d\xE9faut : dossier source de chaque fichier)").option("-f, --format <format>", "format de sortie : md | json | both", "md").option(
2534
+ "--ocr <mode>",
2535
+ "auto (provider configur\xE9) | defer (aucun appel, images extraites)",
2536
+ "auto"
2537
+ ).option("--advanced", "OCR des images embarqu\xE9es (docx, pptx, pdf, xlsx, html, eml)").option("--pdf-ocr <mode>", "PDF via OCR page par page : all | first | page").option("--page <n>", "num\xE9ro de page pour --pdf-ocr page", parsePositiveInt).option(
2538
+ "--max-ocr-calls <n>",
2539
+ "budget maximum d\u2019appels OCR pour cette ex\xE9cution",
2540
+ parsePositiveInt
2541
+ ).option("--translate <locale>", "traduit le markdown final (fr | en | it | de | es)", parseLocale).option("--batch", "accept\xE9 pour compatibilit\xE9 (les dossiers sont d\xE9velopp\xE9s automatiquement)").option("--json", "sortie machine JSON sur stdout (aucun habillage)").addHelpText(
2542
+ "after",
2543
+ `
2544
+ Exemples :
2545
+ sygal convert rapport.pdf
2546
+ sygal convert dossier/ --batch --format both --output ./sorties
2547
+ sygal convert scan.pdf --pdf-ocr all --json
2548
+ sygal convert dossier.docx --advanced --ocr defer --json
2549
+ sygal convert https://example.com/article --json`
2550
+ ).action(
2551
+ async (paths, opts) => {
2552
+ const json = opts.json === true;
2553
+ const format = opts.format;
2554
+ if (!["md", "json", "both"].includes(format)) {
2555
+ fail(json, "invalid_format", `Format inconnu : ${opts.format} (md | json | both)`);
2556
+ return;
2557
+ }
2558
+ const ocrMode = opts.ocr;
2559
+ if (!["auto", "defer"].includes(ocrMode)) {
2560
+ fail(json, "invalid_options", `Mode OCR inconnu : ${opts.ocr} (auto | defer)`);
2561
+ return;
2562
+ }
2563
+ if (opts.pdfOcr && !["all", "first", "page"].includes(opts.pdfOcr)) {
2564
+ fail(
2565
+ json,
2566
+ "invalid_options",
2567
+ `Mode --pdf-ocr inconnu : ${opts.pdfOcr} (all | first | page)`
2568
+ );
2569
+ return;
2570
+ }
2571
+ if (opts.translate && ocrMode === "defer") {
2572
+ fail(
2573
+ json,
2574
+ "invalid_options",
2575
+ "La traduction (--translate) est incompatible avec --ocr defer"
2576
+ );
2577
+ return;
2578
+ }
2579
+ const license = new CliLicense();
2580
+ try {
2581
+ await requireLicensed(license);
2582
+ } catch (err) {
2583
+ const e = toCliError(err);
2584
+ fail(json, e.code, e.message);
2585
+ return;
2586
+ }
2587
+ const sidecarCommand = resolveSidecarCommand();
2588
+ if (!sidecarCommand) {
2589
+ fail(
2590
+ json,
2591
+ "sidecar_not_found",
2592
+ "Moteur de conversion introuvable : r\xE9installez sygal-cli (npm install -g sygal-cli) ou d\xE9finissez SYGAL_SIDECAR_PATH"
2593
+ );
2594
+ return;
2595
+ }
2596
+ const settings = new CliSettings();
2597
+ const appSettings = settings.getAppSettings();
2598
+ const hasKey = settings.hasApiKey(appSettings.ocrProvider);
2599
+ const ocr = hasKey ? new ApiOcrRunner(settings) : null;
2600
+ const translationRunner = hasKey ? new TranslationRunner(settings) : null;
2601
+ let singleFile = false;
2602
+ if (paths.length === 1 && !/^https?:\/\//i.test(paths[0])) {
2603
+ try {
2604
+ singleFile = (0, import_node_fs7.statSync)(paths[0]).isFile();
2605
+ } catch {
2606
+ singleFile = true;
2607
+ }
2608
+ }
2609
+ const sidecar = new SidecarPool({
2610
+ ...sidecarCommand,
2611
+ poolSize: paths.length === 1 && (singleFile || /^https?:\/\//i.test(paths[0])) ? 1 : 2,
2612
+ logger: { info: () => {
2613
+ }, warn: () => {
2614
+ }, error: () => {
2615
+ } }
2616
+ });
2617
+ sidecar.start();
2618
+ try {
2619
+ await sidecar.ping();
2620
+ } catch (err) {
2621
+ sidecar.stop();
2622
+ const message = err instanceof SidecarError ? err.message : String(err);
2623
+ fail(json, "sidecar_down", `Moteur de conversion injoignable : ${message}`);
2624
+ return;
2625
+ }
2626
+ const reporter = json ? null : new HumanReporter();
2627
+ try {
2628
+ const summary = await runConvert(
2629
+ paths,
2630
+ {
2631
+ output: opts.output,
2632
+ format,
2633
+ ocrMode,
2634
+ advanced: opts.advanced ?? settings.isAdvancedConversionEnabled(),
2635
+ pdfOcr: opts.pdfOcr,
2636
+ pdfPage: opts.page,
2637
+ maxOcrCalls: opts.maxOcrCalls ?? settings.getMaxOcrCalls(),
2638
+ translate: opts.translate
2639
+ },
2640
+ {
2641
+ sidecar,
2642
+ ocr,
2643
+ ocrProvider: appSettings.ocrProvider,
2644
+ translate: translationRunner ? (markdown, target) => translationRunner.translate(markdown, target) : void 0
2645
+ },
2646
+ reporter ? {
2647
+ onExpanded: (total) => reporter.onExpanded(total),
2648
+ onReport: (report) => reporter.onReport(report)
2649
+ } : void 0
2650
+ );
2651
+ if (json) {
2652
+ printJson(summary, pkg.version);
2653
+ } else {
2654
+ reporter?.finish(summary, license.getStatus());
2655
+ }
2656
+ process.exitCode = summary.failed > 0 ? 1 : 0;
2657
+ } finally {
2658
+ sidecar.stop();
2659
+ }
2660
+ }
2661
+ );
2662
+ program.command("inject").description(
2663
+ "R\xE9int\xE8gre des transcriptions OCR (mode --ocr defer) dans un markdown, \xE0 leur position exacte"
2664
+ ).argument("<markdown>", "fichier .md contenant des marqueurs ![sygal:ocr:N](...)").requiredOption(
2665
+ "--transcripts <file>",
2666
+ 'JSON : [{"index":1,"text":"...","provider":"..."}] (ocrText/ocrProvider accept\xE9s)'
2667
+ ).option("--json", "sortie machine JSON sur stdout").action(async (markdownPath, opts) => {
2668
+ const json = opts.json === true;
2669
+ try {
2670
+ await requireLicensed(new CliLicense());
2671
+ } catch (err) {
2672
+ const e = toCliError(err);
2673
+ fail(json, e.code, e.message);
2674
+ return;
2675
+ }
2676
+ let transcripts;
2677
+ try {
2678
+ const raw = JSON.parse((0, import_node_fs7.readFileSync)(opts.transcripts, "utf-8"));
2679
+ transcripts = Array.isArray(raw) ? raw : [];
2680
+ } catch (err) {
2681
+ fail(json, "invalid_transcripts", `Fichier de transcriptions illisible : ${String(err)}`);
2682
+ return;
2683
+ }
2684
+ try {
2685
+ const result = await injectTranscripts(markdownPath, transcripts);
2686
+ if (json) {
2687
+ process.stdout.write(JSON.stringify({ ...result, file: markdownPath }, null, 2) + "\n");
2688
+ } else {
2689
+ console.log(
2690
+ `${import_picocolors3.default.green("\u2713")} ${result.injected} transcription(s) inject\xE9e(s)` + (result.remaining > 0 ? ` \xB7 ${import_picocolors3.default.yellow(`${result.remaining} marqueur(s) restant(s)`)}` : "")
2691
+ );
2692
+ }
2693
+ process.exitCode = 0;
2694
+ } catch (err) {
2695
+ fail(json, "inject_failed", err instanceof Error ? err.message : String(err));
2696
+ }
2697
+ });
2698
+ program.command("update").description("V\xE9rifie et installe la derni\xE8re version (npm install -g sygal-cli)").option("--check", "v\xE9rifie seulement, sans installer").option("--json", "sortie machine JSON").action(async (opts) => {
2699
+ const check = await checkForUpdate(pkg.version);
2700
+ if (opts.json) {
2701
+ process.stdout.write(JSON.stringify(check, null, 2) + "\n");
2702
+ if (check.error) process.exitCode = 1;
2703
+ if (opts.check || !check.updateAvailable) return;
2704
+ } else {
2705
+ if (check.error) {
2706
+ console.error(`${import_picocolors3.default.red("\u2717")} ${check.error}`);
2707
+ process.exitCode = 1;
2708
+ return;
2709
+ }
2710
+ if (!check.updateAvailable) {
2711
+ console.log(`${import_picocolors3.default.green("\u2713")} Sygal CLI est \xE0 jour (${check.current})`);
2712
+ return;
2713
+ }
2714
+ console.log(
2715
+ `${import_picocolors3.default.magenta("\u2191")} Nouvelle version disponible : ${check.current} \u2192 ${check.latest}`
2716
+ );
2717
+ if (opts.check) return;
2718
+ console.log(import_picocolors3.default.dim("Installation via npm..."));
2719
+ }
2720
+ const code = await runGlobalInstall();
2721
+ if (code === 0) {
2722
+ if (!opts.json) console.log(`${import_picocolors3.default.green("\u2713")} Sygal CLI mis \xE0 jour en ${check.latest}`);
2723
+ } else {
2724
+ if (!opts.json) {
2725
+ console.error(`${import_picocolors3.default.red("\u2717")} \xC9chec de l\u2019installation (npm code ${code})`);
2726
+ }
2727
+ process.exitCode = 1;
2728
+ }
2729
+ });
2730
+ var licenseCmd = program.command("license").description(`Licence Sygal CLI - essai 14 jours puis activation LemonSqueezy (${licensePath()})`);
2731
+ licenseCmd.command("activate").description("Active une licence sur cette machine (consomme un poste du pool)").argument("<key>", "cl\xE9 de licence LemonSqueezy").option("--json", "sortie machine JSON").action(async (key, opts) => {
2732
+ const result = await new CliLicense().activate(key);
2733
+ if (result.ok) {
2734
+ if (opts.json) {
2735
+ process.stdout.write(JSON.stringify({ ok: true, state: "active" }, null, 2) + "\n");
2736
+ } else {
2737
+ console.log(`${import_picocolors3.default.green("\u2713")} Licence activ\xE9e sur cette machine`);
2738
+ }
2739
+ return;
2740
+ }
2741
+ fail(opts.json === true, "activation_failed", `Activation refus\xE9e : ${result.error}`);
2742
+ });
2743
+ licenseCmd.command("status").description("\xC9tat de la licence (cl\xE9 jamais affich\xE9e en entier)").option("--json", "sortie machine JSON").action(async (opts) => {
2744
+ const license = new CliLicense();
2745
+ await license.validateIfStale();
2746
+ const status = license.getStatus();
2747
+ if (opts.json) {
2748
+ const payload = status.state === "active" ? { state: "active", licenseKey: license.maskedKey() } : status.state === "trial" ? { state: "trial", daysLeft: status.daysLeft } : { state: "expired", store: LS_STORE_URL };
2749
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2750
+ return;
2751
+ }
2752
+ if (status.state === "active") {
2753
+ console.log(`${import_picocolors3.default.green("\u2713")} Licence active (${license.maskedKey()})`);
2754
+ } else if (status.state === "trial") {
2755
+ console.log(`${import_picocolors3.default.yellow("\u25F7")} Essai : ${status.daysLeft} jour(s) restant(s)`);
2756
+ } else {
2757
+ console.log(`${import_picocolors3.default.red("\u2717")} Essai termin\xE9 - achetez une licence : ${LS_STORE_URL}`);
2758
+ process.exitCode = 1;
2759
+ }
2760
+ });
2761
+ licenseCmd.command("deactivate").description("D\xE9sactive la licence sur cette machine (lib\xE8re le poste)").option("--json", "sortie machine JSON").action(async (opts) => {
2762
+ const result = await new CliLicense().deactivate();
2763
+ if (result.ok) {
2764
+ if (opts.json) {
2765
+ process.stdout.write(JSON.stringify({ ok: true }, null, 2) + "\n");
2766
+ } else {
2767
+ console.log(`${import_picocolors3.default.green("\u2713")} Licence d\xE9sactiv\xE9e - poste lib\xE9r\xE9`);
2768
+ }
2769
+ return;
2770
+ }
2771
+ fail(opts.json === true, "deactivation_failed", result.error ?? "D\xE9sactivation refus\xE9e");
2772
+ });
2773
+ var config = program.command("config").description(`Configuration Sygal CLI (${configPath()}, cl\xE9s chiffr\xE9es au repos)`);
2774
+ config.command("set-ocr-provider").description("Choisit le provider OCR actif").argument("<provider>", OCR_PROVIDERS_IDS.join(" | "), parseProvider).action((provider) => {
2775
+ new CliSettings().setOcrProvider(provider);
2776
+ console.log(`${import_picocolors3.default.green("\u2713")} Provider OCR : ${provider}`);
2777
+ });
2778
+ config.command("set-api-key").description("Enregistre la cl\xE9 API d\u2019un provider (chiffr\xE9e, li\xE9e \xE0 cette machine)").argument("<provider>", OCR_PROVIDERS_IDS.join(" | "), parseProvider).argument("[key]", "cl\xE9 API \u2014 omise : lue sur stdin (recommand\xE9, \xE9vite l\u2019historique shell)").action(async (provider, key) => {
2779
+ const value = key ?? await readStdin();
2780
+ if (!value.trim()) {
2781
+ console.error(`${import_picocolors3.default.red("\u2717")} Aucune cl\xE9 fournie (argument ou stdin)`);
2782
+ process.exitCode = 1;
2783
+ return;
2784
+ }
2785
+ new CliSettings().setApiKey(provider, value.trim());
2786
+ console.log(`${import_picocolors3.default.green("\u2713")} Cl\xE9 API ${provider} enregistr\xE9e (chiffr\xE9e)`);
2787
+ });
2788
+ config.command("clear-api-key").description("Supprime la cl\xE9 API d\u2019un provider").argument("<provider>", OCR_PROVIDERS_IDS.join(" | "), parseProvider).action((provider) => {
2789
+ new CliSettings().clearApiKey(provider);
2790
+ console.log(`${import_picocolors3.default.green("\u2713")} Cl\xE9 API ${provider} supprim\xE9e`);
2791
+ });
2792
+ config.command("set-locale").description("Langue des libell\xE9s ajout\xE9s par l\u2019OCR et cible par d\xE9faut").argument("<locale>", LOCALES2.join(" | "), parseLocale).action((locale) => {
2793
+ new CliSettings().setLocale(locale);
2794
+ console.log(`${import_picocolors3.default.green("\u2713")} Langue : ${locale}`);
2795
+ });
2796
+ config.command("set-advanced").description("Active/d\xE9sactive l\u2019OCR des images embarqu\xE9es par d\xE9faut").argument("<state>", "on | off").action((state) => {
2797
+ if (!["on", "off"].includes(state)) {
2798
+ console.error(`${import_picocolors3.default.red("\u2717")} Valeur attendue : on | off`);
2799
+ process.exitCode = 1;
2800
+ return;
2801
+ }
2802
+ new CliSettings().setAdvancedConversion(state === "on");
2803
+ console.log(`${import_picocolors3.default.green("\u2713")} Conversion avanc\xE9e : ${state}`);
2804
+ });
2805
+ config.command("set-max-ocr-calls").description("Budget d\u2019appels OCR par ex\xE9cution (garde-fou de co\xFBt) \u2014 off = illimit\xE9").argument("<limit>", "entier >= 1, ou off").action((limit) => {
2806
+ const settings = new CliSettings();
2807
+ if (limit === "off") {
2808
+ settings.setMaxOcrCalls(null);
2809
+ console.log(`${import_picocolors3.default.green("\u2713")} Budget OCR : illimit\xE9`);
2810
+ return;
2811
+ }
2812
+ const n = Number(limit);
2813
+ if (!Number.isInteger(n) || n < 1) {
2814
+ console.error(`${import_picocolors3.default.red("\u2717")} Valeur attendue : entier >= 1, ou off`);
2815
+ process.exitCode = 1;
2816
+ return;
2817
+ }
2818
+ settings.setMaxOcrCalls(n);
2819
+ console.log(`${import_picocolors3.default.green("\u2713")} Budget OCR : ${n} appel(s) max par ex\xE9cution`);
2820
+ });
2821
+ config.command("show").description("Affiche la configuration (pr\xE9sence des cl\xE9s uniquement, jamais leur valeur)").option("--json", "sortie machine JSON").action((opts) => {
2822
+ const described = new CliSettings().describe();
2823
+ if (opts.json) {
2824
+ process.stdout.write(JSON.stringify(described, null, 2) + "\n");
2825
+ return;
2826
+ }
2827
+ console.log(`Config : ${configPath()}`);
2828
+ console.log(`Langue : ${described.locale}`);
2829
+ console.log(`Provider OCR : ${described.ocrProvider}`);
2830
+ console.log(`Conversion avanc\xE9e : ${described.advancedConversion ? "on" : "off"}`);
2831
+ console.log(`Budget OCR : ${described.maxOcrCalls ?? "illimit\xE9"}`);
2832
+ console.log("Cl\xE9s API :");
2833
+ for (const [provider, present] of Object.entries(described.apiKeys)) {
2834
+ console.log(` ${provider} : ${present ? import_picocolors3.default.green("configur\xE9e") : import_picocolors3.default.dim("absente")}`);
2835
+ }
2836
+ });
2837
+ function readStdin() {
2838
+ return new Promise((resolvePromise) => {
2839
+ let data = "";
2840
+ process.stdin.setEncoding("utf-8");
2841
+ process.stdin.on("data", (chunk) => data += chunk);
2842
+ process.stdin.on("end", () => resolvePromise(data));
2843
+ });
2844
+ }
2845
+ function fail(json, code, message) {
2846
+ if (json) {
2847
+ process.stdout.write(
2848
+ JSON.stringify({ summary: { done: 0, failed: 1 }, error: { code, message } }, null, 2) + "\n"
2849
+ );
2850
+ } else {
2851
+ console.error(`${import_picocolors3.default.red("\u2717")} ${message} ${import_picocolors3.default.dim(`[${code}]`)}`);
2852
+ }
2853
+ process.exitCode = 1;
2854
+ }
2855
+ program.parseAsync(process.argv).catch((err) => {
2856
+ console.error(import_picocolors3.default.red(String(err instanceof Error ? err.message : err)));
2857
+ process.exitCode = 1;
2858
+ });