redmine-context 1.0.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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- package/package.json +93 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extrator de texto de PDF via `pdftotext` (poppler) — M4-extra, #145, ADR-002.
|
|
3
|
+
*
|
|
4
|
+
* Roda o binário `pdftotext` sobre um PDF já baixado no cache e devolve a CAMADA
|
|
5
|
+
* DE TEXTO do documento num {@link ExtractionResult}. ESPELHA ponto-a-ponto o
|
|
6
|
+
* padrão do {@link TesseractExtractor} (ADR-002), todas as decisões exercitadas
|
|
7
|
+
* por testes:
|
|
8
|
+
*
|
|
9
|
+
* - INVOCAÇÃO SEM SHELL: usa `execFile` (nunca `exec`/shell) com lista de
|
|
10
|
+
* argumentos explícita (`pdftotext -enc UTF-8 <file> -`, `-` = stdout) — o
|
|
11
|
+
* `filePath` NUNCA é interpolado numa string de shell, eliminando injeção por
|
|
12
|
+
* nome de arquivo malicioso.
|
|
13
|
+
* - ENV SANITIZADO: o subprocesso recebe um env MÍNIMO e EXPLÍCITO (só `PATH`) —
|
|
14
|
+
* segredos do processo pai (ex.: `REDMINE_API_KEY`) NUNCA vazam para o pdftotext.
|
|
15
|
+
* - TIMEOUT + KILL: um watchdog envia `SIGTERM` no timeout e, após a graça,
|
|
16
|
+
* `SIGKILL` — um pdftotext travado (PDF patológico) não pendura a fila de jobs.
|
|
17
|
+
* - DEGRADAÇÃO GRACIOSA: binário ausente do PATH/locais convencionais NÃO lança;
|
|
18
|
+
* devolve `{ status: 'failed', metadata.reason }` com dica de instalação (o
|
|
19
|
+
* `doctor` da #53 orienta o usuário).
|
|
20
|
+
*
|
|
21
|
+
* SEMÂNTICA DO PDF SEM CAMADA DE TEXTO (escaneado): quando o `pdftotext` sai com
|
|
22
|
+
* sucesso mas a saída é VAZIA ou só whitespace (form-feed por página), NÃO
|
|
23
|
+
* devolvemos `{ status: 'done', text: '' }` — isso MENTIRIA ao consumidor,
|
|
24
|
+
* afirmando "extração concluída, sem texto" quando na verdade não há camada de
|
|
25
|
+
* texto extraível (o conteúdo é imagem, exigindo OCR). Devolvemos
|
|
26
|
+
* `{ status: 'failed', reason: 'pdf-sem-camada-de-texto' }` com um `hint` de que
|
|
27
|
+
* o OCR por página fica para uma issue futura — falha honesta e diagnosticável.
|
|
28
|
+
*
|
|
29
|
+
* A chave de cache do anexo (ADR-004) depende de `version` + `model` + `params`
|
|
30
|
+
* ({@link buildAttachmentKey}); por isso o extrator expõe {@link PdfExtractor.version}
|
|
31
|
+
* (derivada do binário real quando detectável, senão a versão da integração) e
|
|
32
|
+
* {@link PdfExtractor.params} (inclui `enc`/`layout`, estáveis para a chave).
|
|
33
|
+
*/
|
|
34
|
+
import { execFile } from 'node:child_process';
|
|
35
|
+
import { accessSync, constants } from 'node:fs';
|
|
36
|
+
import { delimiter, join } from 'node:path';
|
|
37
|
+
/** Identificador estável do extrator (entra em metadados). */
|
|
38
|
+
const EXTRACTOR_ID = 'pdftotext';
|
|
39
|
+
/** Modelo lógico para a chave de cache (ADR-004). PDF→texto não versiona "modelo". */
|
|
40
|
+
const EXTRACTOR_MODEL = 'pdftotext';
|
|
41
|
+
/**
|
|
42
|
+
* Versão de FALLBACK da integração, usada quando o binário não é detectável (não
|
|
43
|
+
* instalado) e portanto sua versão não pode ser lida. Mantém `version` estável e
|
|
44
|
+
* não-vazia para {@link buildAttachmentKey}.
|
|
45
|
+
*/
|
|
46
|
+
const INTEGRATION_VERSION = 'pdftotext-integration-1';
|
|
47
|
+
/** Encoding de saída — sempre UTF-8 (participa da chave de cache via `params`). */
|
|
48
|
+
const OUTPUT_ENCODING = 'UTF-8';
|
|
49
|
+
/** Timeout default de uma extração antes do `SIGTERM` (ms). */
|
|
50
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
51
|
+
/** Graça entre `SIGTERM` e `SIGKILL` (ms) — dá ao pdftotext chance de sair limpo. */
|
|
52
|
+
const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
53
|
+
/** Teto do stdout capturado (32 MiB) — PDFs textuais densos cabem com folga. */
|
|
54
|
+
const MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
|
55
|
+
/** MIME REAL (magic bytes `%PDF`, ver `./magic.ts`) que o pdftotext aceita. */
|
|
56
|
+
export const PDF_MIMES = ['application/pdf'];
|
|
57
|
+
/**
|
|
58
|
+
* Locais convencionais do binário `pdftotext`, por plataforma — consultados após
|
|
59
|
+
* o `PATH`. No Windows, os builds do poppler (oschwartz10612.Poppler / choco)
|
|
60
|
+
* instalam em `…\poppler\bin`. Reutilizados pelo `doctor` (#53) via {@link findPdftotext}.
|
|
61
|
+
*/
|
|
62
|
+
const CONVENTIONAL_UNIX = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'];
|
|
63
|
+
const CONVENTIONAL_WINDOWS = [
|
|
64
|
+
'C:\\Program Files\\poppler\\bin',
|
|
65
|
+
'C:\\Program Files\\poppler\\Library\\bin',
|
|
66
|
+
'C:\\poppler\\bin',
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Verifica se um caminho aponta para um arquivo executável (bit X), sem lançar.
|
|
70
|
+
*
|
|
71
|
+
* @param candidate - Caminho absoluto candidato ao binário.
|
|
72
|
+
* @returns `true` se o arquivo existe e é executável pelo processo atual.
|
|
73
|
+
*/
|
|
74
|
+
function isExecutable(candidate) {
|
|
75
|
+
try {
|
|
76
|
+
accessSync(candidate, constants.X_OK);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Localiza o binário `pdftotext` no `PATH` e em locais convencionais por
|
|
85
|
+
* plataforma (`/opt/homebrew/bin`, `/usr/local/bin`, `C:\\Program Files\\poppler\\bin`).
|
|
86
|
+
* Função pura e reutilizável pelo `doctor` (#53). Não executa o binário.
|
|
87
|
+
*
|
|
88
|
+
* @returns A localização encontrada, ou `undefined` se não instalado.
|
|
89
|
+
* @example
|
|
90
|
+
* const found = findPdftotext();
|
|
91
|
+
* if (found === undefined) logger.warn('pdftotext (poppler) não instalado');
|
|
92
|
+
*/
|
|
93
|
+
export function findPdftotext() {
|
|
94
|
+
const isWindows = process.platform === 'win32';
|
|
95
|
+
const exe = isWindows ? 'pdftotext.exe' : 'pdftotext';
|
|
96
|
+
const pathDirs = (process.env.PATH ?? '').split(delimiter).filter((dir) => dir.length > 0);
|
|
97
|
+
const conventional = isWindows ? CONVENTIONAL_WINDOWS : CONVENTIONAL_UNIX;
|
|
98
|
+
for (const dir of [...pathDirs, ...conventional]) {
|
|
99
|
+
const candidate = join(dir, exe);
|
|
100
|
+
if (isExecutable(candidate)) {
|
|
101
|
+
return { path: candidate };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Monta o env MÍNIMO e EXPLÍCITO do subprocesso (ADR-002). Só repassa `PATH`;
|
|
108
|
+
* NENHUM outro segredo do pai (ex.: `REDMINE_API_KEY`) é herdado.
|
|
109
|
+
*
|
|
110
|
+
* @returns Env sanitizado para o subprocesso pdftotext.
|
|
111
|
+
*/
|
|
112
|
+
function sanitizedEnv() {
|
|
113
|
+
return { PATH: process.env.PATH ?? '/usr/bin:/bin' };
|
|
114
|
+
}
|
|
115
|
+
/** Erro interno: o watchdog matou o pdftotext por estourar o timeout. */
|
|
116
|
+
class PdftotextTimeoutError extends Error {
|
|
117
|
+
timeoutMs;
|
|
118
|
+
constructor(timeoutMs) {
|
|
119
|
+
super(`pdftotext excedeu o timeout de ${timeoutMs}ms e foi encerrado`);
|
|
120
|
+
this.timeoutMs = timeoutMs;
|
|
121
|
+
this.name = 'PdftotextTimeoutError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Executa `pdftotext -enc UTF-8 [-layout] <file> -` SEM shell, com env sanitizado
|
|
126
|
+
* e watchdog de timeout (`SIGTERM` → graça → `SIGKILL`). O `-` final direciona a
|
|
127
|
+
* saída para o stdout.
|
|
128
|
+
*
|
|
129
|
+
* @param options - Ver {@link RunOptions}.
|
|
130
|
+
* @returns O stdout (texto do PDF) do pdftotext.
|
|
131
|
+
* @throws {PdftotextTimeoutError} Se estourar o timeout.
|
|
132
|
+
* @throws {Error} Se o binário falhar (exit != 0, não encontrado em runtime, etc.).
|
|
133
|
+
*/
|
|
134
|
+
function runPdftotext(options) {
|
|
135
|
+
const { bin, filePath, layout, timeoutMs, killGraceMs } = options;
|
|
136
|
+
const args = ['-enc', OUTPUT_ENCODING, ...(layout ? ['-layout'] : []), filePath, '-'];
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let settled = false;
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
const timers = [];
|
|
141
|
+
const cleanup = () => {
|
|
142
|
+
for (const timer of timers)
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
};
|
|
145
|
+
const settle = (fn) => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return;
|
|
148
|
+
settled = true;
|
|
149
|
+
cleanup();
|
|
150
|
+
fn();
|
|
151
|
+
};
|
|
152
|
+
const child = execFile(bin, args, { env: sanitizedEnv(), encoding: 'utf8', maxBuffer: MAX_BUFFER_BYTES, windowsHide: true }, (error, stdout) => {
|
|
153
|
+
if (timedOut) {
|
|
154
|
+
settle(() => reject(new PdftotextTimeoutError(timeoutMs)));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (error !== null) {
|
|
158
|
+
settle(() => reject(error));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
settle(() => resolve(stdout));
|
|
162
|
+
});
|
|
163
|
+
timers.push(setTimeout(() => {
|
|
164
|
+
timedOut = true;
|
|
165
|
+
child.kill('SIGTERM');
|
|
166
|
+
// Reason: após a graça, força SIGKILL e desiste — um processo que ignora
|
|
167
|
+
// SIGTERM não pode segurar a fila de jobs indefinidamente (ADR-002).
|
|
168
|
+
timers.push(setTimeout(() => {
|
|
169
|
+
child.kill('SIGKILL');
|
|
170
|
+
settle(() => reject(new PdftotextTimeoutError(timeoutMs)));
|
|
171
|
+
}, killGraceMs));
|
|
172
|
+
}, timeoutMs));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Extrator de texto de PDF baseado no binário `pdftotext` do poppler (ADR-002).
|
|
177
|
+
* Implementa {@link Extractor} e, além do contrato, expõe {@link params} e
|
|
178
|
+
* {@link extractorConfig} estáveis para {@link buildAttachmentKey}.
|
|
179
|
+
*/
|
|
180
|
+
export class PdfExtractor {
|
|
181
|
+
id = EXTRACTOR_ID;
|
|
182
|
+
version;
|
|
183
|
+
supportedMimes = PDF_MIMES;
|
|
184
|
+
/** Modelo lógico para a chave de cache (ADR-004). */
|
|
185
|
+
model = EXTRACTOR_MODEL;
|
|
186
|
+
/** Parâmetros escalares estáveis (`enc`, `layout`) — participam da chave de cache. */
|
|
187
|
+
params;
|
|
188
|
+
binaryPath;
|
|
189
|
+
layout;
|
|
190
|
+
timeoutMs;
|
|
191
|
+
killGraceMs;
|
|
192
|
+
/**
|
|
193
|
+
* @param options - Ver {@link PdfExtractorOptions}.
|
|
194
|
+
*/
|
|
195
|
+
constructor(options) {
|
|
196
|
+
this.version = options.version;
|
|
197
|
+
this.binaryPath = options.binaryPath;
|
|
198
|
+
this.layout = options.layout ?? false;
|
|
199
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
200
|
+
this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
201
|
+
this.params = { enc: OUTPUT_ENCODING, layout: this.layout };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Configuração do extrator pronta para {@link buildAttachmentKey} (ADR-004).
|
|
205
|
+
* @returns `{ version, model, params }` estáveis desta instância.
|
|
206
|
+
*/
|
|
207
|
+
get extractorConfig() {
|
|
208
|
+
return { version: this.version, model: this.model, params: this.params };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Extrai a camada de texto de `filePath`. Nunca lança: falhas (binário ausente,
|
|
212
|
+
* timeout, erro de execução, PDF sem texto) viram `{ status: 'failed',
|
|
213
|
+
* metadata.reason }` — degradação graciosa (ADR-002).
|
|
214
|
+
*
|
|
215
|
+
* IMPORTANTE (semântica): um PDF ESCANEADO (sem camada de texto) produz saída
|
|
216
|
+
* vazia/whitespace no `pdftotext`; nesse caso devolvemos `failed` com
|
|
217
|
+
* `reason: 'pdf-sem-camada-de-texto'` (e um `hint` de OCR), e NÃO
|
|
218
|
+
* `{ status: 'done', text: '' }` — que afirmaria falsamente sucesso sem texto.
|
|
219
|
+
*
|
|
220
|
+
* @param filePath - Caminho absoluto do PDF baixado.
|
|
221
|
+
* @param options - MIME real + logger — ver {@link ExtractOptions}.
|
|
222
|
+
* @returns `done` com `text` no sucesso; `failed` com motivo claro na falha.
|
|
223
|
+
*/
|
|
224
|
+
async extract(filePath, options) {
|
|
225
|
+
const bin = this.binaryPath;
|
|
226
|
+
if (bin === undefined) {
|
|
227
|
+
options.logger?.warn('pdftotext: binário não encontrado no PATH nem em locais convencionais; ' +
|
|
228
|
+
'instale o poppler (ex.: `brew install poppler`) — veja o doctor');
|
|
229
|
+
return this.failed(options.mime, 'pdftotext-nao-instalado', {
|
|
230
|
+
hint: 'instale o poppler (pdftotext); o doctor (#53) valida a instalação',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
let stdout;
|
|
234
|
+
try {
|
|
235
|
+
stdout = await runPdftotext({
|
|
236
|
+
bin,
|
|
237
|
+
filePath,
|
|
238
|
+
layout: this.layout,
|
|
239
|
+
timeoutMs: this.timeoutMs,
|
|
240
|
+
killGraceMs: this.killGraceMs,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const isTimeout = error instanceof PdftotextTimeoutError;
|
|
245
|
+
return this.failed(options.mime, isTimeout ? 'timeout' : 'erro-execucao', {
|
|
246
|
+
error: error instanceof Error ? error.message : String(error),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const text = stdout.trim();
|
|
250
|
+
if (text.length === 0) {
|
|
251
|
+
// PDF escaneado / sem camada de texto: `done` com text vazio MENTIRIA.
|
|
252
|
+
return this.failed(options.mime, 'pdf-sem-camada-de-texto', {
|
|
253
|
+
hint: 'PDF sem camada de texto (provavelmente escaneado) — OCR por página fica para issue futura',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
status: 'done',
|
|
258
|
+
text,
|
|
259
|
+
mime: options.mime,
|
|
260
|
+
metadata: { extractorId: this.id, version: this.version, layout: this.layout },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Monta um `ExtractionResult` `failed` com metadados de diagnóstico, sem
|
|
265
|
+
* injetar chaves `undefined` (respeita `exactOptionalPropertyTypes`).
|
|
266
|
+
*
|
|
267
|
+
* @param mime - MIME real detectado (do dispatcher).
|
|
268
|
+
* @param reason - Motivo canônico da falha.
|
|
269
|
+
* @param extra - Metadados adicionais (hint/erro).
|
|
270
|
+
* @returns Resultado `failed` tipado.
|
|
271
|
+
*/
|
|
272
|
+
failed(mime, reason, extra) {
|
|
273
|
+
return {
|
|
274
|
+
status: 'failed',
|
|
275
|
+
mime,
|
|
276
|
+
metadata: { extractorId: this.id, version: this.version, reason, ...extra },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Lê a versão do binário via `pdftotext -v`. O poppler imprime
|
|
282
|
+
* `pdftotext version X.Y.Z` no STDERR (não stdout) e sai com código 0. Não lança:
|
|
283
|
+
* retorna `undefined` se o binário falhar ou a saída for inesperada.
|
|
284
|
+
*
|
|
285
|
+
* @param bin - Caminho absoluto do binário.
|
|
286
|
+
* @returns A versão semântica detectada (ex.: `24.02.0`), ou `undefined`.
|
|
287
|
+
*/
|
|
288
|
+
export function detectPdftotextVersion(bin) {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
execFile(bin, ['-v'], { env: sanitizedEnv(), encoding: 'utf8', windowsHide: true, timeout: DEFAULT_KILL_GRACE_MS }, (error, stdout, stderr) => {
|
|
291
|
+
// Reason: `pdftotext -v` sai com status 0 e escreve a versão no STDERR;
|
|
292
|
+
// toleramos `error` não-nulo e ainda tentamos parsear ambos os streams.
|
|
293
|
+
const combined = `${stderr}${stdout}`;
|
|
294
|
+
const match = /pdftotext\s+version\s+(\d+\.\d+\.\d+)/i.exec(combined);
|
|
295
|
+
if (match === null && error !== null) {
|
|
296
|
+
resolve(undefined);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
resolve(match?.[1]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Cria um {@link PdfExtractor} resolvendo binário e versão. Localiza o
|
|
305
|
+
* `pdftotext` ({@link findPdftotext}); se presente, lê a versão real do binário e
|
|
306
|
+
* expõe `version = pdftotext-<X.Y.Z>`; se ausente (não instalado) ou versão
|
|
307
|
+
* ilegível, usa {@link INTEGRATION_VERSION} e o extrator degrada em `extract`.
|
|
308
|
+
*
|
|
309
|
+
* @param config - Sobrescreve `layout`/timeouts — ver {@link PdfExtractorOptions}.
|
|
310
|
+
* @returns O extrator pronto para registro no {@link ExtractorRegistry}.
|
|
311
|
+
* @example
|
|
312
|
+
* const extractor = await createPdfExtractor({ layout: true });
|
|
313
|
+
*/
|
|
314
|
+
export async function createPdfExtractor(config = {}) {
|
|
315
|
+
const found = findPdftotext();
|
|
316
|
+
let version = INTEGRATION_VERSION;
|
|
317
|
+
if (found !== undefined) {
|
|
318
|
+
const detected = await detectPdftotextVersion(found.path);
|
|
319
|
+
version = detected !== undefined ? `pdftotext-${detected}` : INTEGRATION_VERSION;
|
|
320
|
+
}
|
|
321
|
+
return new PdfExtractor({ ...config, binaryPath: found?.path, version });
|
|
322
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Núcleo da fila de jobs de extração (#67 / M4-10.1, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* A extração de mídia é longa (minutos) e há vários anexos por issue; esta fila
|
|
5
|
+
* roda os jobs com **concorrência limitada** (`os.cpus().length − 1`, piso de 1),
|
|
6
|
+
* expõe as **transições de estado de forma observável** e **isola falhas** — um
|
|
7
|
+
* job que rejeita vira `failed` sem derrubar os demais.
|
|
8
|
+
*
|
|
9
|
+
* ## Observabilidade — contrato do core (ADR-005)
|
|
10
|
+
*
|
|
11
|
+
* A fila é consumida como `AsyncIterable<QueueEvent<T>>`, o mesmo padrão
|
|
12
|
+
* `AsyncIterable<ProgressEvent | Result>` que as superfícies já usam: cada
|
|
13
|
+
* transição de um job é um item da sequência, e o iterável termina quando a fila
|
|
14
|
+
* drena. O status REUSA o vocabulário canônico {@link ExtractionStatus}
|
|
15
|
+
* (`pending → processing → done | failed | cancelled`) — sem status paralelo.
|
|
16
|
+
*
|
|
17
|
+
* ## Cancelamento (#69)
|
|
18
|
+
*
|
|
19
|
+
* A fila aceita um `AbortSignal` opcional ({@link RunQueueOptions.signal}) e o
|
|
20
|
+
* propaga a cada job via {@link JobContext.signal}. Ao abortar: os jobs em
|
|
21
|
+
* execução recebem o signal (o extrator o repassa ao `runWithWatchdog`, que MATA
|
|
22
|
+
* o subprocesso) e são observados como `cancelled`; os jobs `pending` que ainda
|
|
23
|
+
* não iniciaram são marcados `cancelled` sem iniciar. Cada transição emite UM
|
|
24
|
+
* evento — as superfícies consomem só o `AsyncIterable`, sem API de cancelamento
|
|
25
|
+
* acoplada.
|
|
26
|
+
*
|
|
27
|
+
* ## Fronteira e escopo
|
|
28
|
+
*
|
|
29
|
+
* Módulo de core (pipeline de extração): não importa de `src/surfaces/**` e não
|
|
30
|
+
* usa `console.*` — falhas são reportadas via {@link Logger} injetado. O
|
|
31
|
+
* {@link JobContext} passado a cada job é o ponto de extensão do timeout/kill de
|
|
32
|
+
* subprocesso (#68, `timeoutMs`) e do cancelamento (#69, `signal`), sem quebrar a
|
|
33
|
+
* assinatura de {@link QueueJob}.
|
|
34
|
+
*/
|
|
35
|
+
import type { Logger } from '../client/index.js';
|
|
36
|
+
import type { ExtractionStatus } from '../contract.js';
|
|
37
|
+
/**
|
|
38
|
+
* Estado observável de um job da fila — subconjunto do vocabulário canônico
|
|
39
|
+
* {@link ExtractionStatus}. Derivado por `Extract<>` para que qualquer mudança
|
|
40
|
+
* no contrato do core se propague aqui (sem status paralelo).
|
|
41
|
+
*/
|
|
42
|
+
export type QueueJobStatus = Extract<ExtractionStatus, 'pending' | 'processing' | 'done' | 'failed' | 'cancelled'>;
|
|
43
|
+
/**
|
|
44
|
+
* Contexto passado ao {@link QueueJob.run} de cada job.
|
|
45
|
+
*
|
|
46
|
+
* Carrega o `jobId` (útil para logs/telemetria do próprio job) e, opcionalmente,
|
|
47
|
+
* o `timeoutMs` — o ORÇAMENTO DE TEMPO que um job de extração deve repassar ao
|
|
48
|
+
* watchdog do subprocesso (#68), que o encerra com `SIGTERM`→`SIGKILL` no estouro.
|
|
49
|
+
* A fila NÃO cancela o job por conta própria (o kill correto vive no subprocesso);
|
|
50
|
+
* ela só PROPAGA o orçamento. É o seam reservado ao `AbortSignal` do cancelamento
|
|
51
|
+
* (#69), que entra aqui como mais um campo OPCIONAL — aditivo, sem quebrar jobs
|
|
52
|
+
* existentes.
|
|
53
|
+
*/
|
|
54
|
+
export interface JobContext {
|
|
55
|
+
/** Id do job em execução (o mesmo de {@link QueueJob.id}). */
|
|
56
|
+
readonly jobId: string;
|
|
57
|
+
/**
|
|
58
|
+
* Orçamento de tempo (ms) que o job deve aplicar ao seu subprocesso, quando a
|
|
59
|
+
* fila é criada com {@link RunQueueOptions.jobTimeoutMs}. Ausente = o job usa seu
|
|
60
|
+
* próprio default. Respeita `exactOptionalPropertyTypes` (nunca `undefined`).
|
|
61
|
+
*/
|
|
62
|
+
readonly timeoutMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Sinal de CANCELAMENTO (#69, ADR-005) propagado a cada job em execução quando
|
|
65
|
+
* a fila é criada com {@link RunQueueOptions.signal}. O job de extração o repassa
|
|
66
|
+
* ao `runWithWatchdog`, que MATA o subprocesso (`SIGTERM`→`SIGKILL`) ao abortar;
|
|
67
|
+
* a fila então observa o job como `cancelled`. Ausente = a fila não é cancelável.
|
|
68
|
+
* Presente só quando a fila recebe um signal (respeita `exactOptionalPropertyTypes`).
|
|
69
|
+
*/
|
|
70
|
+
readonly signal?: AbortSignal;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Uma unidade de trabalho da fila. `run` é uma função async qualquer — nos
|
|
74
|
+
* testes, um job FAKE controlável via Promise; em produção, a extração de um
|
|
75
|
+
* anexo.
|
|
76
|
+
*
|
|
77
|
+
* @typeParam T - Tipo do resultado produzido pelo job em caso de sucesso.
|
|
78
|
+
*/
|
|
79
|
+
export interface QueueJob<T> {
|
|
80
|
+
/** Identificador único da execução (escolhido por quem enfileira). */
|
|
81
|
+
readonly id: string;
|
|
82
|
+
/**
|
|
83
|
+
* Executa o trabalho.
|
|
84
|
+
*
|
|
85
|
+
* @param context - Contexto da execução — ver {@link JobContext}.
|
|
86
|
+
* @returns O resultado do job; a rejeição vira uma transição `failed`.
|
|
87
|
+
*/
|
|
88
|
+
run(context: JobContext): Promise<T>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Uma transição de estado observável de um job (item do `AsyncIterable`).
|
|
92
|
+
*
|
|
93
|
+
* Respeita `exactOptionalPropertyTypes`: `result` só aparece em `done`,
|
|
94
|
+
* `reason`/`error` só aparecem em `failed` — nunca com valor `undefined`.
|
|
95
|
+
*
|
|
96
|
+
* @typeParam T - Tipo do resultado do job (presente em `status === 'done'`).
|
|
97
|
+
*/
|
|
98
|
+
export interface QueueEvent<T> {
|
|
99
|
+
/** Id do job ({@link QueueJob.id}) a que esta transição se refere. */
|
|
100
|
+
readonly id: string;
|
|
101
|
+
/** Novo estado do job. */
|
|
102
|
+
readonly status: QueueJobStatus;
|
|
103
|
+
/** Resultado do job — presente apenas quando `status === 'done'`. */
|
|
104
|
+
readonly result?: T;
|
|
105
|
+
/** Motivo legível da falha — presente apenas quando `status === 'failed'`. */
|
|
106
|
+
readonly reason?: string;
|
|
107
|
+
/** Erro bruto que causou a falha — presente apenas quando `status === 'failed'`. */
|
|
108
|
+
readonly error?: unknown;
|
|
109
|
+
}
|
|
110
|
+
/** Opções de {@link runQueue}. */
|
|
111
|
+
export interface RunQueueOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Número máximo de jobs em `processing` ao mesmo tempo. Injetável para testes
|
|
114
|
+
* determinísticos; valores `< 1` são elevados ao piso de 1. Default:
|
|
115
|
+
* {@link defaultConcurrency}.
|
|
116
|
+
*/
|
|
117
|
+
readonly concurrency?: number;
|
|
118
|
+
/** Logger para avisar sobre jobs que falharam (boundary ADR-005; sem `console.*`). */
|
|
119
|
+
readonly logger?: Logger;
|
|
120
|
+
/**
|
|
121
|
+
* Orçamento de tempo (ms) propagado a cada job via {@link JobContext.timeoutMs}
|
|
122
|
+
* (#68) — os jobs de extração o repassam ao watchdog do subprocesso. Omitido =
|
|
123
|
+
* cada job usa seu próprio default. A fila NÃO cancela o job por conta própria.
|
|
124
|
+
*/
|
|
125
|
+
readonly jobTimeoutMs?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Sinal de CANCELAMENTO da fila inteira (#69, ADR-005). Ao disparar: os jobs em
|
|
128
|
+
* execução recebem o signal via {@link JobContext.signal} (matam o subprocesso e
|
|
129
|
+
* rejeitam) e são observados como `cancelled`; os jobs `pending` que ainda NÃO
|
|
130
|
+
* iniciaram são marcados `cancelled` sem iniciar. Cada transição emite UM evento.
|
|
131
|
+
* Omitido = a fila não é cancelável (comportamento #67/#68 inalterado).
|
|
132
|
+
*/
|
|
133
|
+
readonly signal?: AbortSignal;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Limite de concorrência padrão da fila: `os.cpus().length − 1`, com piso de 1
|
|
137
|
+
* para máquinas de 1 núcleo (ADR-005 — deixa um núcleo livre para a UI/event
|
|
138
|
+
* loop enquanto o binário externo trabalha).
|
|
139
|
+
*
|
|
140
|
+
* @returns O número máximo de jobs simultâneos recomendado para esta máquina.
|
|
141
|
+
*/
|
|
142
|
+
export declare function defaultConcurrency(): number;
|
|
143
|
+
/**
|
|
144
|
+
* Roda uma coleção de jobs com concorrência limitada, expondo cada transição de
|
|
145
|
+
* estado como um `AsyncIterable<QueueEvent<T>>` (contrato do core, ADR-005).
|
|
146
|
+
*
|
|
147
|
+
* Garantias:
|
|
148
|
+
* - **Concorrência**: nunca há mais que `concurrency` jobs em `processing` ao
|
|
149
|
+
* mesmo tempo; os excedentes ficam em `pending` até um slot liberar (FIFO —
|
|
150
|
+
* a ordem de enfileiramento é respeitada).
|
|
151
|
+
* - **Observabilidade**: todo job emite `pending` (no enfileiramento), depois
|
|
152
|
+
* `processing` (ao iniciar) e por fim `done` (com `result`) ou `failed` (com
|
|
153
|
+
* `reason`/`error`).
|
|
154
|
+
* - **Isolamento de falha**: um job que rejeita vira `failed` e NÃO interrompe
|
|
155
|
+
* os demais — a fila continua até drenar. `runQueue` nunca rejeita por causa
|
|
156
|
+
* de um job.
|
|
157
|
+
*
|
|
158
|
+
* @typeParam T - Tipo do resultado dos jobs em caso de sucesso.
|
|
159
|
+
* @param jobs - Jobs a executar, na ordem de prioridade (FIFO).
|
|
160
|
+
* @param options - Concorrência e logger — ver {@link RunQueueOptions}.
|
|
161
|
+
* @returns Sequência assíncrona de transições, terminada quando a fila drena.
|
|
162
|
+
* @example
|
|
163
|
+
* for await (const event of runQueue(jobs, { concurrency: 2 })) {
|
|
164
|
+
* if (event.status === 'failed') logger.warn(event.reason ?? 'falhou');
|
|
165
|
+
* }
|
|
166
|
+
*/
|
|
167
|
+
export declare function runQueue<T>(jobs: Iterable<QueueJob<T>>, options?: RunQueueOptions): AsyncIterable<QueueEvent<T>>;
|