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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. 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>>;