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,215 @@
1
+ /**
2
+ * Download do modelo GGUF do whisper.cpp com verificação de integridade
3
+ * (M4-02, #58, ADR-002).
4
+ *
5
+ * A transcrição local (ADR-002) exige um modelo `.gguf`, artefato independente do
6
+ * binário `whisper-cli` ({@link findWhisper}). Este módulo baixa esse modelo de
7
+ * forma AUDITÁVEL e SEGURA, respeitando a política híbrida de binários do ADR-002:
8
+ *
9
+ * - CONSTANTES PINADAS: URL HTTPS oficial ({@link GGUF_MODEL_URL}) e SHA-256
10
+ * esperado ({@link GGUF_MODEL_SHA256}) ficam no código, não em config remota — a
11
+ * integridade não depende de o servidor "dizer" qual é o hash certo.
12
+ * - SÓ HTTPS: qualquer URL `http://` é RECUSADA antes de tocar a rede (defesa
13
+ * contra downgrade/MITM em ambiente corporativo).
14
+ * - CHECKSUM OBRIGATÓRIO: o conteúdo é gravado num `.part`, seu SHA-256 é comparado
15
+ * ao pinado e só então renomeado para o destino; divergência → o `.part` é
16
+ * DESCARTADO e um erro claro (esperado vs. obtido) é lançado.
17
+ * - RETOMADA VIA HTTP RANGE (M4-03, #59): se um `.part` de N bytes já existe, o
18
+ * request pede `Range: bytes=N-` e ANEXA os bytes recebidos (`206 Partial Content`),
19
+ * evitando rebaixar do zero. O checksum é sempre do arquivo COMPLETO. Fallbacks
20
+ * graciosos: servidor que IGNORA o Range (`200 OK` com o corpo inteiro) recomeça
21
+ * do zero sem corromper; `.part` inservível (`416`) é descartado e o download
22
+ * reinicia limpo.
23
+ * - NUNCA EM MCP HEADLESS: o download é OPT-IN INTERATIVO (`--download-binaries`,
24
+ * ADR-002). Quando a chamada sinaliza ambiente MCP/headless ({@link DownloadGgufOptions.headless}),
25
+ * a função RECUSA antes de qualquer IO, com erro acionável — download silencioso
26
+ * de executáveis/modelos é vetor de supply chain.
27
+ *
28
+ * Todas as dependências de efeito colateral (fetch/fs) são INJETÁVEIS
29
+ * ({@link GgufDeps}) para tornar os testes herméticos — sem rede nem FS real.
30
+ */
31
+ import { createHash } from 'node:crypto';
32
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
33
+ import { join } from 'node:path';
34
+ import { whisperModelDir } from './whisper.js';
35
+ /**
36
+ * Modelo GGUF default: `ggml-tiny` (multilíngue, ~78 MB) do repositório oficial
37
+ * `ggerganov/whisper.cpp` no Hugging Face. Escolhido por ser o MENOR modelo
38
+ * multilíngue oficial — mantém o auto-detect de idioma do ADR-002 com o menor
39
+ * custo de download para o primeiro contato. Modelos maiores (base/small) são
40
+ * evolução futura configurável; a MECÂNICA de download+verificação é idêntica.
41
+ */
42
+ export const GGUF_MODEL_NAME = 'ggml-tiny.bin';
43
+ /** URL HTTPS oficial e fixa do {@link GGUF_MODEL_NAME} (Hugging Face resolve). */
44
+ export const GGUF_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin';
45
+ /**
46
+ * SHA-256 hex esperado do {@link GGUF_MODEL_NAME}. É a ÂNCORA de integridade:
47
+ * o arquivo baixado só é aceito se seu hash bater exatamente com esta constante.
48
+ */
49
+ export const GGUF_MODEL_SHA256 = 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21';
50
+ /** Sufixo do arquivo parcial durante a escrita, antes do rename para o destino. */
51
+ const PART_SUFFIX = '.part';
52
+ /**
53
+ * Erro tipado de qualquer recusa/falha do download do modelo GGUF. O campo
54
+ * {@link GgufDownloadError.code} permite ao chamador (CLI/doctor) reagir de forma
55
+ * específica sem casar strings de mensagem.
56
+ */
57
+ export class GgufDownloadError extends Error {
58
+ /** Categoria da falha — ver {@link GgufDownloadFailure}. */
59
+ code;
60
+ constructor(code, message) {
61
+ super(message);
62
+ this.name = new.target.name;
63
+ this.code = code;
64
+ }
65
+ }
66
+ /**
67
+ * Constrói as dependências reais de produção: `fetch` global e `node:fs/promises`
68
+ * (`mkdir` recursivo, `rm` com `force`). Isolado para manter {@link downloadGgufModel}
69
+ * testável sem tocar rede/FS.
70
+ *
71
+ * @returns Deps ligadas ao ambiente real.
72
+ */
73
+ export function defaultGgufDeps() {
74
+ return {
75
+ fetch: (url, init) => fetch(url, init),
76
+ mkdir: async (dir) => {
77
+ await mkdir(dir, { recursive: true });
78
+ },
79
+ writeFile: (path, data) => writeFile(path, data),
80
+ // flag 'a': cria o arquivo se ausente e sempre anexa ao fim (retomada).
81
+ appendFile: (path, data) => writeFile(path, data, { flag: 'a' }),
82
+ readPart: async (path) => new Uint8Array(await readFile(path)),
83
+ // stat inexistente (ENOENT) ⇒ sem `.part` ⇒ download novo (undefined).
84
+ statPart: async (path) => {
85
+ try {
86
+ return (await stat(path)).size;
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ },
92
+ rename: (from, to) => rename(from, to),
93
+ rm: (path) => rm(path, { force: true }),
94
+ };
95
+ }
96
+ /**
97
+ * Baixa o modelo GGUF do whisper.cpp e o grava no cache local, verificando o
98
+ * SHA-256 pinado antes de considerá-lo válido (ADR-002, M4-02).
99
+ *
100
+ * Ordem dos portões (todos exercitados por testes): (1) guard headless — recusa
101
+ * antes de qualquer IO; (2) só HTTPS — recusa `http://` sem tocar a rede; (3)
102
+ * RETOMADA — se um `.part` de N bytes já existe, pede `Range: bytes=N-` e ANEXA o
103
+ * `206`; se o servidor ignora o Range (`200`) recomeça do zero, e um `.part`
104
+ * inservível (`416`) é descartado antes de reiniciar limpo; (4) compara o SHA-256
105
+ * do arquivo COMPLETO ao esperado — se bater, renomeia atomicamente para o destino
106
+ * e devolve o caminho; se não, DESCARTA o `.part` e lança {@link GgufDownloadError}
107
+ * com esperado vs. obtido.
108
+ *
109
+ * @param options - Destino, flag headless, origem/checksum e deps — ver {@link DownloadGgufOptions}.
110
+ * @returns Caminho absoluto do modelo `.gguf`/`.bin` gravado e verificado.
111
+ * @throws {GgufDownloadError} `headless` (ambiente MCP), `insecure-url` (não-HTTPS),
112
+ * `http-error` (resposta não-2xx) ou `checksum-mismatch` (integridade divergente).
113
+ * @example
114
+ * const modelPath = await downloadGgufModel({ headless: false });
115
+ * logger.info(`modelo pronto em ${modelPath}`);
116
+ */
117
+ export async function downloadGgufModel(options = {}) {
118
+ const headless = options.headless ?? false;
119
+ const url = options.url ?? GGUF_MODEL_URL;
120
+ const expected = (options.sha256 ?? GGUF_MODEL_SHA256).toLowerCase();
121
+ const destDir = options.destDir ?? whisperModelDir();
122
+ const deps = options.deps ?? defaultGgufDeps();
123
+ // Portão 1: opt-in interativo (ADR-002). Nunca baixa em MCP/headless.
124
+ if (headless) {
125
+ throw new GgufDownloadError('headless', 'Download do modelo GGUF é opt-in interativo (--download-binaries) e não pode ' +
126
+ 'rodar em ambiente MCP/headless (ADR-002). Execute a CLI interativamente para baixá-lo.');
127
+ }
128
+ // Portão 2: só HTTPS — bloqueia downgrade/MITM antes de qualquer rede. Uma URL
129
+ // malformada é tratada como insegura (não escapa como TypeError cru).
130
+ let protocol;
131
+ try {
132
+ protocol = new URL(url).protocol;
133
+ }
134
+ catch {
135
+ throw new GgufDownloadError('insecure-url', `Download recusado: URL do modelo GGUF inválida ('${url}'). Apenas HTTPS é permitido.`);
136
+ }
137
+ if (protocol !== 'https:') {
138
+ throw new GgufDownloadError('insecure-url', `Download recusado: apenas HTTPS é permitido para o modelo GGUF (protocolo recebido: '${protocol}').`);
139
+ }
140
+ const finalPath = join(destDir, GGUF_MODEL_NAME);
141
+ const partPath = `${finalPath}${PART_SUFFIX}`;
142
+ // Portão 3: retomada. Um `.part` de N>0 bytes vira o ponto de partida do Range;
143
+ // ausente/vazio ⇒ download novo (sem cabeçalho Range).
144
+ const existingSize = await deps.statPart(partPath);
145
+ let rangeStart = existingSize !== undefined && existingSize > 0 ? existingSize : 0;
146
+ let response = await requestModel(deps, url, rangeStart);
147
+ // `.part` inservível para o servidor (ex.: já completo/maior): 416. Descarta o
148
+ // parcial e reinicia limpo UMA vez, para não travar num parcial envenenado.
149
+ if (response.status === 416 && rangeStart > 0) {
150
+ await deps.rm(partPath).catch(() => undefined);
151
+ rangeStart = 0;
152
+ response = await requestModel(deps, url, 0);
153
+ }
154
+ if (!response.ok) {
155
+ throw new GgufDownloadError('http-error', `Falha ao baixar o modelo GGUF: resposta HTTP ${response.status}.`);
156
+ }
157
+ const body = new Uint8Array(await response.arrayBuffer());
158
+ await deps.mkdir(destDir);
159
+ try {
160
+ // Só é retomada de verdade se pedimos Range E o servidor honrou com 206. Caso
161
+ // contrário (200 com o corpo inteiro), o `.part` é sobrescrito do zero — nunca
162
+ // anexamos a um corpo completo, o que corromperia o arquivo.
163
+ let complete;
164
+ if (rangeStart > 0 && response.status === 206) {
165
+ const previous = await deps.readPart(partPath);
166
+ await deps.appendFile(partPath, body);
167
+ complete = concatBytes(previous, body);
168
+ }
169
+ else {
170
+ await deps.writeFile(partPath, body);
171
+ complete = body;
172
+ }
173
+ // Portão 4: integridade do arquivo COMPLETO. Só o hash pinado valida o
174
+ // conteúdo — nunca o servidor.
175
+ const actual = createHash('sha256').update(complete).digest('hex');
176
+ if (actual !== expected) {
177
+ throw new GgufDownloadError('checksum-mismatch', `Integridade do modelo GGUF divergente: esperado ${expected}, obtido ${actual}. Arquivo descartado.`);
178
+ }
179
+ await deps.rename(partPath, finalPath);
180
+ }
181
+ catch (error) {
182
+ // Descarta o `.part` em QUALQUER falha (escrita parcial, checksum ou rename) —
183
+ // nunca deixa lixo nem um destino final corrompido. `rm` é force, idempotente.
184
+ await deps.rm(partPath).catch(() => undefined);
185
+ throw error;
186
+ }
187
+ return finalPath;
188
+ }
189
+ /**
190
+ * Faz o request do modelo, anexando `Range: bytes=<from>-` quando `from > 0` para
191
+ * retomar de um `.part` parcial. Isolado para manter {@link downloadGgufModel}
192
+ * legível e testar o cabeçalho de retomada de forma direta.
193
+ *
194
+ * @param deps - Dependências injetáveis (usa apenas `fetch`).
195
+ * @param url - URL HTTPS já validada do modelo.
196
+ * @param from - Offset de retomada em bytes; `0` faz um GET normal (sem Range).
197
+ * @returns A resposta HTTP (200 no download novo/fallback; 206 na retomada honrada).
198
+ */
199
+ function requestModel(deps, url, from) {
200
+ return from > 0 ? deps.fetch(url, { headers: { Range: `bytes=${from}-` } }) : deps.fetch(url);
201
+ }
202
+ /**
203
+ * Concatena `previous` (bytes já no `.part`) com `appended` (bytes do `206`) para
204
+ * compor o arquivo COMPLETO sobre o qual o SHA-256 é verificado.
205
+ *
206
+ * @param previous - Bytes já presentes no `.part` antes da retomada.
207
+ * @param appended - Bytes recebidos no corpo do `206 Partial Content`.
208
+ * @returns Buffer com o conteúdo completo (`previous` seguido de `appended`).
209
+ */
210
+ function concatBytes(previous, appended) {
211
+ const out = new Uint8Array(previous.length + appended.length);
212
+ out.set(previous);
213
+ out.set(appended, previous.length);
214
+ return out;
215
+ }
@@ -0,0 +1,19 @@
1
+ export declare const MODULE_NAME: "extract";
2
+ export { downloadAttachment, isSkipped, safeExtension, type DownloadAttachmentOptions, type DownloadResult, type SkippedDownload, } from './download.js';
3
+ export { detectMime, detectMimeFromFile, mimeForExtension, MAGIC_SAMPLE_SIZE, } from './magic.js';
4
+ export { dispatchExtraction, ExtractorRegistry, type DispatchOptions, type ExtractOptions, type Extractor, } from './dispatcher.js';
5
+ export { defaultConcurrency, runQueue, type JobContext, type QueueEvent, type QueueJob, type QueueJobStatus, type RunQueueOptions, } from './queue.js';
6
+ export { createDefaultRegistry, createTesseractExtractor, detectTesseractVersion, findTesseract, TesseractExtractor, TESSERACT_MIMES, type TesseractExtractorOptions, type TesseractLocation, } from './tesseract.js';
7
+ export { createPdfExtractor, detectPdftotextVersion, findPdftotext, PdfExtractor, PDF_MIMES, type PdfExtractorOptions, type PdftotextLocation, } from './pdf.js';
8
+ export { findExecutable, isExecutable, type ConventionalDirs, type ExecutableLocation, type FindExecutableDeps, } from './which.js';
9
+ export { runWithWatchdog, sanitizedEnv, SubprocessTimeoutError, type ExecFileLike, type SanitizedEnvOptions, type WatchdogChild, type WatchdogDeps, type WatchdogInvocation, } from './subprocess.js';
10
+ export { detectFfmpegVersion, findFfmpeg, findFfprobe, type FfmpegLocation, type FfprobeLocation, } from './ffmpeg.js';
11
+ export { audioTempDir, convertAudioToWav, type ConvertAudioToWavOptions, type FfmpegInvocation, type FfmpegRunner, type WavConversionFailure, type WavConversionResult, type WavConversionSuccess, } from './audio.js';
12
+ export { findWhisper, whisperModelDir, type WhisperLocation } from './whisper.js';
13
+ export { createWhisperExtractor, parseWhisperOutput, WhisperExtractor, WHISPER_MIMES, type CreateWhisperExtractorOptions, type WhisperExtractorOptions, type WhisperInvocation, type WhisperRunner, } from './whisper-extract.js';
14
+ export { AudioExtractor, AUDIO_MIMES, createAudioExtractor, type AudioExtractorOptions, } from './audio-extractor.js';
15
+ export { createVideoExtractor, VideoExtractor, type VideoExtractorOptions, type VideoPipelineOverrides, } from './video-extractor.js';
16
+ export { createOoxmlExtractor, OoxmlExtractor, OOXML_MIMES } from './ooxml.js';
17
+ export { parseZip, type ZipArchive, type ZipEntry } from './zip.js';
18
+ export { convertVideoToWav, extractVideoKeyframe, extractVideoTranscript, parseFfprobeDuration, probeVideoDuration, VIDEO_MIMES, type DurationProbeOk, type DurationProbeResult, type DurationProbeUnavailable, type ExtractKeyframeOptions, type ExtractVideoTranscriptOptions, type FfprobeInvocation, type FfprobeRunner, type KeyframeFailure, type KeyframeResult, type KeyframeSuccess, type ProbeVideoDurationOptions, } from './video.js';
19
+ export { downloadGgufModel, defaultGgufDeps, GgufDownloadError, GGUF_MODEL_NAME, GGUF_MODEL_URL, GGUF_MODEL_SHA256, type DownloadGgufOptions, type GgufDeps, type GgufFetchInit, type GgufDownloadFailure, } from './gguf.js';
@@ -0,0 +1,19 @@
1
+ export const MODULE_NAME = 'extract';
2
+ export { downloadAttachment, isSkipped, safeExtension, } from './download.js';
3
+ export { detectMime, detectMimeFromFile, mimeForExtension, MAGIC_SAMPLE_SIZE, } from './magic.js';
4
+ export { dispatchExtraction, ExtractorRegistry, } from './dispatcher.js';
5
+ export { defaultConcurrency, runQueue, } from './queue.js';
6
+ export { createDefaultRegistry, createTesseractExtractor, detectTesseractVersion, findTesseract, TesseractExtractor, TESSERACT_MIMES, } from './tesseract.js';
7
+ export { createPdfExtractor, detectPdftotextVersion, findPdftotext, PdfExtractor, PDF_MIMES, } from './pdf.js';
8
+ export { findExecutable, isExecutable, } from './which.js';
9
+ export { runWithWatchdog, sanitizedEnv, SubprocessTimeoutError, } from './subprocess.js';
10
+ export { detectFfmpegVersion, findFfmpeg, findFfprobe, } from './ffmpeg.js';
11
+ export { audioTempDir, convertAudioToWav, } from './audio.js';
12
+ export { findWhisper, whisperModelDir } from './whisper.js';
13
+ export { createWhisperExtractor, parseWhisperOutput, WhisperExtractor, WHISPER_MIMES, } from './whisper-extract.js';
14
+ export { AudioExtractor, AUDIO_MIMES, createAudioExtractor, } from './audio-extractor.js';
15
+ export { createVideoExtractor, VideoExtractor, } from './video-extractor.js';
16
+ export { createOoxmlExtractor, OoxmlExtractor, OOXML_MIMES } from './ooxml.js';
17
+ export { parseZip } from './zip.js';
18
+ export { convertVideoToWav, extractVideoKeyframe, extractVideoTranscript, parseFfprobeDuration, probeVideoDuration, VIDEO_MIMES, } from './video.js';
19
+ export { downloadGgufModel, defaultGgufDeps, GgufDownloadError, GGUF_MODEL_NAME, GGUF_MODEL_URL, GGUF_MODEL_SHA256, } from './gguf.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Detecção de MIME REAL por magic bytes (M3-08, ADR-005).
3
+ *
4
+ * SEGURANÇA: o tipo do anexo é decidido SEMPRE pelos primeiros bytes do arquivo
5
+ * baixado — NUNCA pela extensão do filename nem pelo `Content-Type` reportado
6
+ * pelo Redmine (ambos são atacáveis/mentíveis). Um `.png` que na verdade é um
7
+ * executável, ou um `Content-Type: image/png` sobre um zip, são detectados pelo
8
+ * conteúdo. Sem dependência nova: assinaturas mínimas próprias, tabeladas abaixo.
9
+ *
10
+ * Tabela de assinaturas (offset em bytes → sequência hex):
11
+ *
12
+ * MIME | offset | bytes (hex) | origem
13
+ * --------------------|--------|--------------------------------|------------------
14
+ * image/png | 0 | 89 50 4E 47 0D 0A 1A 0A | PNG signature
15
+ * image/jpeg | 0 | FF D8 FF | JFIF/EXIF SOI
16
+ * image/gif | 0 | 47 49 46 38 (GIF8) | GIF87a/GIF89a
17
+ * image/webp | 0 + 8 | 52 49 46 46 (RIFF) + 57 45 42 50| RIFF container
18
+ * application/pdf | 0 | 25 50 44 46 (%PDF) | PDF header
19
+ * application/zip | 0 | 50 4B 03 04 (PK\x03\x04) | ZIP local header (docx/xlsx/pptx)
20
+ * audio/wav | 0 + 8 | 52 49 46 46 (RIFF) + 57 41 56 45| RIFF/WAVE container
21
+ * video/x-msvideo | 0 + 8 | 52 49 46 46 (RIFF) + 41 56 49 20| RIFF/AVI container
22
+ * audio/ogg | 0 | 4F 67 67 53 (OggS) | Ogg container
23
+ * video/webm | 0 | 1A 45 DF A3 (EBML) | Matroska/WebM (EBML)
24
+ * audio/mpeg | 0 | 49 44 33 (ID3) ou FF Ex sync | MP3 (tag ID3 ou frame sync)
25
+ *
26
+ * CONTÊINERES ISO-BMFF (MP4/MOV/M4A, M4-14/#73): a box `ftyp` fica no offset 4
27
+ * (bytes 4-8 == 'ftyp'); a MARCA (brand, bytes 8-12) decide áudio vs vídeo. Brands
28
+ * de áudio (`M4A `, `M4B `) → `audio/mp4`; qualquer outra (`isom`, `mp42`, `qt `,
29
+ * `avc1`, …) → `video/mp4`. Isso roteia o mesmo container para o extrator certo
30
+ * (whisper direto para áudio; pipeline de vídeo → áudio → whisper para vídeo).
31
+ *
32
+ * Fallback: se nenhuma assinatura casar e a amostra for "texto plausível"
33
+ * (sem byte NUL e maioria de bytes imprimíveis), classifica como `text/plain`.
34
+ * Caso contrário, retorna `undefined` (desconhecido/binário sem extrator).
35
+ *
36
+ * DOCX/XLSX/PPTX são contêineres ZIP: a detecção por magic bytes os reporta como
37
+ * `application/zip` — a diferenciação fina (inspecionar `[Content_Types].xml`)
38
+ * fica para o extrator específico, fora do escopo do dispatcher.
39
+ */
40
+ /**
41
+ * Tamanho da amostra lida do início do arquivo. Cobre a maior assinatura
42
+ * (webp precisa de 12 bytes) com folga e dá base estatística ao teste de texto.
43
+ */
44
+ export declare const MAGIC_SAMPLE_SIZE = 512;
45
+ /**
46
+ * Detecta o MIME REAL de uma amostra de bytes via a tabela de assinaturas, com
47
+ * fallback para `text/plain` quando a amostra é texto plausível.
48
+ *
49
+ * @param bytes - Amostra do início do arquivo (idealmente {@link MAGIC_SAMPLE_SIZE}).
50
+ * @returns O MIME detectado, ou `undefined` se desconhecido/binário sem assinatura.
51
+ * @example
52
+ * detectMime(new Uint8Array([0x25, 0x50, 0x44, 0x46])); // 'application/pdf'
53
+ * detectMime(new Uint8Array([])); // undefined
54
+ */
55
+ export declare function detectMime(bytes: Uint8Array): string | undefined;
56
+ /**
57
+ * Lê o prefixo de um arquivo e detecta seu MIME REAL por magic bytes.
58
+ *
59
+ * Lê no máximo {@link MAGIC_SAMPLE_SIZE} bytes (não carrega o arquivo inteiro),
60
+ * então fecha o descritor. Arquivos vazios ou muito curtos resultam em
61
+ * `undefined` — o dispatcher os trata como `unsupported`, sem erro fatal.
62
+ *
63
+ * @param filePath - Caminho absoluto do arquivo baixado.
64
+ * @returns O MIME detectado, ou `undefined` se desconhecido.
65
+ * @throws {Error} Se o arquivo não puder ser aberto/lido (ex.: não existe).
66
+ */
67
+ export declare function detectMimeFromFile(filePath: string): Promise<string | undefined>;
68
+ /**
69
+ * Deriva o MIME que a EXTENSÃO de um filename declara — usado somente para o
70
+ * aviso de mismatch (o magic byte é a fonte de verdade). Retorna `undefined`
71
+ * para extensões ausentes ou não mapeadas (nesse caso não há mismatch a avisar).
72
+ *
73
+ * @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
74
+ * @returns O MIME declarado pela extensão, ou `undefined`.
75
+ * @example
76
+ * mimeForExtension('photo.PNG'); // 'image/png'
77
+ * mimeForExtension('report.pdf'); // 'application/pdf'
78
+ * mimeForExtension('noext'); // undefined
79
+ */
80
+ export declare function mimeForExtension(filename: string): string | undefined;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Detecção de MIME REAL por magic bytes (M3-08, ADR-005).
3
+ *
4
+ * SEGURANÇA: o tipo do anexo é decidido SEMPRE pelos primeiros bytes do arquivo
5
+ * baixado — NUNCA pela extensão do filename nem pelo `Content-Type` reportado
6
+ * pelo Redmine (ambos são atacáveis/mentíveis). Um `.png` que na verdade é um
7
+ * executável, ou um `Content-Type: image/png` sobre um zip, são detectados pelo
8
+ * conteúdo. Sem dependência nova: assinaturas mínimas próprias, tabeladas abaixo.
9
+ *
10
+ * Tabela de assinaturas (offset em bytes → sequência hex):
11
+ *
12
+ * MIME | offset | bytes (hex) | origem
13
+ * --------------------|--------|--------------------------------|------------------
14
+ * image/png | 0 | 89 50 4E 47 0D 0A 1A 0A | PNG signature
15
+ * image/jpeg | 0 | FF D8 FF | JFIF/EXIF SOI
16
+ * image/gif | 0 | 47 49 46 38 (GIF8) | GIF87a/GIF89a
17
+ * image/webp | 0 + 8 | 52 49 46 46 (RIFF) + 57 45 42 50| RIFF container
18
+ * application/pdf | 0 | 25 50 44 46 (%PDF) | PDF header
19
+ * application/zip | 0 | 50 4B 03 04 (PK\x03\x04) | ZIP local header (docx/xlsx/pptx)
20
+ * audio/wav | 0 + 8 | 52 49 46 46 (RIFF) + 57 41 56 45| RIFF/WAVE container
21
+ * video/x-msvideo | 0 + 8 | 52 49 46 46 (RIFF) + 41 56 49 20| RIFF/AVI container
22
+ * audio/ogg | 0 | 4F 67 67 53 (OggS) | Ogg container
23
+ * video/webm | 0 | 1A 45 DF A3 (EBML) | Matroska/WebM (EBML)
24
+ * audio/mpeg | 0 | 49 44 33 (ID3) ou FF Ex sync | MP3 (tag ID3 ou frame sync)
25
+ *
26
+ * CONTÊINERES ISO-BMFF (MP4/MOV/M4A, M4-14/#73): a box `ftyp` fica no offset 4
27
+ * (bytes 4-8 == 'ftyp'); a MARCA (brand, bytes 8-12) decide áudio vs vídeo. Brands
28
+ * de áudio (`M4A `, `M4B `) → `audio/mp4`; qualquer outra (`isom`, `mp42`, `qt `,
29
+ * `avc1`, …) → `video/mp4`. Isso roteia o mesmo container para o extrator certo
30
+ * (whisper direto para áudio; pipeline de vídeo → áudio → whisper para vídeo).
31
+ *
32
+ * Fallback: se nenhuma assinatura casar e a amostra for "texto plausível"
33
+ * (sem byte NUL e maioria de bytes imprimíveis), classifica como `text/plain`.
34
+ * Caso contrário, retorna `undefined` (desconhecido/binário sem extrator).
35
+ *
36
+ * DOCX/XLSX/PPTX são contêineres ZIP: a detecção por magic bytes os reporta como
37
+ * `application/zip` — a diferenciação fina (inspecionar `[Content_Types].xml`)
38
+ * fica para o extrator específico, fora do escopo do dispatcher.
39
+ */
40
+ import { open } from 'node:fs/promises';
41
+ /**
42
+ * Tamanho da amostra lida do início do arquivo. Cobre a maior assinatura
43
+ * (webp precisa de 12 bytes) com folga e dá base estatística ao teste de texto.
44
+ */
45
+ export const MAGIC_SAMPLE_SIZE = 512;
46
+ /** Razão mínima de bytes imprimíveis para classificar a amostra como texto. */
47
+ const TEXT_PRINTABLE_RATIO = 0.95;
48
+ /** Byte NUL — sua presença descarta imediatamente a hipótese de texto. */
49
+ const NUL_BYTE = 0x00;
50
+ /**
51
+ * Tabela de assinaturas, avaliada em ordem. A primeira que casar vence — as
52
+ * assinaturas são disjuntas (prefixos distintos), então a ordem não é ambígua.
53
+ */
54
+ const SIGNATURES = [
55
+ { mime: 'image/png', segments: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }] },
56
+ { mime: 'image/jpeg', segments: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] },
57
+ { mime: 'image/gif', segments: [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }] },
58
+ {
59
+ mime: 'image/webp',
60
+ segments: [
61
+ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
62
+ { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
63
+ ],
64
+ },
65
+ { mime: 'application/pdf', segments: [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }] },
66
+ { mime: 'application/zip', segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }] },
67
+ // RIFF/WAVE (áudio) e RIFF/AVI (vídeo): mesmo prefixo RIFF do webp, distinguidos
68
+ // pelo subtipo no offset 8 (segmento dupla-checado, como o webp).
69
+ {
70
+ mime: 'audio/wav',
71
+ segments: [
72
+ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
73
+ { offset: 8, bytes: [0x57, 0x41, 0x56, 0x45] },
74
+ ],
75
+ },
76
+ {
77
+ mime: 'video/x-msvideo',
78
+ segments: [
79
+ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
80
+ { offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] },
81
+ ],
82
+ },
83
+ // Ogg (áudio) — o whisper transcreve a faixa; classificamos como áudio (ADR-005).
84
+ { mime: 'audio/ogg', segments: [{ offset: 0, bytes: [0x4f, 0x67, 0x67, 0x53] }] },
85
+ // EBML (Matroska/WebM): compartilham a assinatura; roteamos ao pipeline de vídeo,
86
+ // que extrai a faixa de áudio (webm/mkv só-áudio ainda transcreve; keyframe degrada).
87
+ { mime: 'video/webm', segments: [{ offset: 0, bytes: [0x1a, 0x45, 0xdf, 0xa3] }] },
88
+ // MP3 com tag ID3v2 no início (o frame sync puro é tratado à parte, ver detectMime).
89
+ { mime: 'audio/mpeg', segments: [{ offset: 0, bytes: [0x49, 0x44, 0x33] }] },
90
+ ];
91
+ /** Segmento da box `ftyp` de um contêiner ISO-BMFF (MP4/MOV/M4A): offset 4. */
92
+ const FTYP_SEGMENT = { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] };
93
+ /**
94
+ * Brands (marca no offset 8-12) de ISO-BMFF que classificam o container como
95
+ * ÁUDIO: `M4A ` e `M4B `. Qualquer outra brand é tratada como vídeo (`video/mp4`).
96
+ */
97
+ const AUDIO_FTYP_BRANDS = [
98
+ [0x4d, 0x34, 0x41, 0x20], // 'M4A '
99
+ [0x4d, 0x34, 0x42, 0x20], // 'M4B '
100
+ ];
101
+ /**
102
+ * Detecta o MIME de um contêiner ISO-BMFF (MP4/MOV/M4A) pela box `ftyp` (offset 4)
103
+ * e sua brand (offset 8). Brands de áudio → `audio/mp4`; demais → `video/mp4`.
104
+ *
105
+ * @param bytes - Amostra do início do arquivo.
106
+ * @returns `audio/mp4` | `video/mp4` se for ISO-BMFF; `undefined` caso contrário.
107
+ */
108
+ function detectFtypMime(bytes) {
109
+ if (!matchesSegment(bytes, FTYP_SEGMENT)) {
110
+ return undefined;
111
+ }
112
+ const isAudio = AUDIO_FTYP_BRANDS.some((brand) => matchesSegment(bytes, { offset: 8, bytes: brand }));
113
+ return isAudio ? 'audio/mp4' : 'video/mp4';
114
+ }
115
+ /**
116
+ * `true` se a amostra começa com um MPEG audio frame sync (MP3 sem tag ID3): o
117
+ * primeiro byte é `0xFF` e os 3 bits mais altos do segundo byte são `1` (máscara
118
+ * `0xE0`). Distingue-se do JPEG (`FF D8`), cujo `0xD8 & 0xE0 = 0xC0`, e do ftyp.
119
+ *
120
+ * @param bytes - Amostra do início do arquivo.
121
+ * @returns `true` se casar o frame sync de MP3.
122
+ */
123
+ function isMp3FrameSync(bytes) {
124
+ const b0 = bytes[0];
125
+ const b1 = bytes[1];
126
+ if (b0 === undefined || b1 === undefined) {
127
+ return false;
128
+ }
129
+ return b0 === 0xff && (b1 & 0xe0) === 0xe0;
130
+ }
131
+ /**
132
+ * Mapa extensão → MIME esperado. USADO APENAS para detectar mismatch entre a
133
+ * extensão declarada e o magic byte real (o magic byte sempre vence, ver
134
+ * dispatcher). NUNCA é fonte de verdade para roteamento.
135
+ */
136
+ const EXTENSION_MIME = {
137
+ png: 'image/png',
138
+ jpg: 'image/jpeg',
139
+ jpeg: 'image/jpeg',
140
+ gif: 'image/gif',
141
+ webp: 'image/webp',
142
+ pdf: 'application/pdf',
143
+ zip: 'application/zip',
144
+ docx: 'application/zip',
145
+ xlsx: 'application/zip',
146
+ pptx: 'application/zip',
147
+ txt: 'text/plain',
148
+ md: 'text/plain',
149
+ log: 'text/plain',
150
+ csv: 'text/plain',
151
+ // Contêineres de áudio/vídeo (M4-14/#73): mapeados para o MIME que o `detectMime`
152
+ // produz para o mesmo conteúdo, minimizando avisos de mismatch espúrios. O `mov`
153
+ // é declarado como `video/mp4` porque a brand `qt ` é classificada como vídeo.
154
+ mp4: 'video/mp4',
155
+ m4v: 'video/mp4',
156
+ mov: 'video/mp4',
157
+ webm: 'video/webm',
158
+ mkv: 'video/webm',
159
+ avi: 'video/x-msvideo',
160
+ m4a: 'audio/mp4',
161
+ m4b: 'audio/mp4',
162
+ mp3: 'audio/mpeg',
163
+ wav: 'audio/wav',
164
+ ogg: 'audio/ogg',
165
+ oga: 'audio/ogg',
166
+ };
167
+ /**
168
+ * Verifica se um único segmento de assinatura casa com a amostra.
169
+ *
170
+ * @param bytes - Amostra lida do início do arquivo.
171
+ * @param segment - Segmento (offset + bytes esperados).
172
+ * @returns `true` se todos os bytes do segmento casam na amostra.
173
+ */
174
+ function matchesSegment(bytes, segment) {
175
+ if (segment.offset + segment.bytes.length > bytes.length) {
176
+ return false;
177
+ }
178
+ for (let i = 0; i < segment.bytes.length; i += 1) {
179
+ if (bytes[segment.offset + i] !== segment.bytes[i]) {
180
+ return false;
181
+ }
182
+ }
183
+ return true;
184
+ }
185
+ /**
186
+ * Heurística de "texto plausível": sem byte NUL e com ao menos
187
+ * {@link TEXT_PRINTABLE_RATIO} de bytes imprimíveis (ASCII visível, espaços em
188
+ * branco comuns ou bytes ≥ 0x80, que cobrem sequências UTF-8). Uma amostra
189
+ * vazia NÃO é texto (retorna `false`).
190
+ *
191
+ * @param bytes - Amostra lida do início do arquivo.
192
+ * @returns `true` se a amostra parece texto legível.
193
+ */
194
+ function isPlausibleText(bytes) {
195
+ if (bytes.length === 0) {
196
+ return false;
197
+ }
198
+ let printable = 0;
199
+ for (const byte of bytes) {
200
+ if (byte === NUL_BYTE) {
201
+ return false;
202
+ }
203
+ // Reason: tab/LF/CR + ASCII visível (0x20–0x7E) + alto (≥0x80 UTF-8) contam
204
+ // como imprimíveis; demais bytes de controle derrubam a razão.
205
+ const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
206
+ const isVisibleAscii = byte >= 0x20 && byte <= 0x7e;
207
+ const isHighByte = byte >= 0x80;
208
+ if (isWhitespace || isVisibleAscii || isHighByte) {
209
+ printable += 1;
210
+ }
211
+ }
212
+ return printable / bytes.length >= TEXT_PRINTABLE_RATIO;
213
+ }
214
+ /**
215
+ * Detecta o MIME REAL de uma amostra de bytes via a tabela de assinaturas, com
216
+ * fallback para `text/plain` quando a amostra é texto plausível.
217
+ *
218
+ * @param bytes - Amostra do início do arquivo (idealmente {@link MAGIC_SAMPLE_SIZE}).
219
+ * @returns O MIME detectado, ou `undefined` se desconhecido/binário sem assinatura.
220
+ * @example
221
+ * detectMime(new Uint8Array([0x25, 0x50, 0x44, 0x46])); // 'application/pdf'
222
+ * detectMime(new Uint8Array([])); // undefined
223
+ */
224
+ export function detectMime(bytes) {
225
+ for (const signature of SIGNATURES) {
226
+ if (signature.segments.every((segment) => matchesSegment(bytes, segment))) {
227
+ return signature.mime;
228
+ }
229
+ }
230
+ // Contêineres que exigem lógica além de casar bytes fixos: ISO-BMFF (a brand
231
+ // decide áudio vs vídeo) e MP3 sem tag ID3 (frame sync com máscara de bits).
232
+ const ftyp = detectFtypMime(bytes);
233
+ if (ftyp !== undefined) {
234
+ return ftyp;
235
+ }
236
+ if (isMp3FrameSync(bytes)) {
237
+ return 'audio/mpeg';
238
+ }
239
+ return isPlausibleText(bytes) ? 'text/plain' : undefined;
240
+ }
241
+ /**
242
+ * Lê o prefixo de um arquivo e detecta seu MIME REAL por magic bytes.
243
+ *
244
+ * Lê no máximo {@link MAGIC_SAMPLE_SIZE} bytes (não carrega o arquivo inteiro),
245
+ * então fecha o descritor. Arquivos vazios ou muito curtos resultam em
246
+ * `undefined` — o dispatcher os trata como `unsupported`, sem erro fatal.
247
+ *
248
+ * @param filePath - Caminho absoluto do arquivo baixado.
249
+ * @returns O MIME detectado, ou `undefined` se desconhecido.
250
+ * @throws {Error} Se o arquivo não puder ser aberto/lido (ex.: não existe).
251
+ */
252
+ export async function detectMimeFromFile(filePath) {
253
+ const handle = await open(filePath, 'r');
254
+ try {
255
+ const buffer = Buffer.alloc(MAGIC_SAMPLE_SIZE);
256
+ const { bytesRead } = await handle.read(buffer, 0, MAGIC_SAMPLE_SIZE, 0);
257
+ return detectMime(buffer.subarray(0, bytesRead));
258
+ }
259
+ finally {
260
+ await handle.close();
261
+ }
262
+ }
263
+ /**
264
+ * Deriva o MIME que a EXTENSÃO de um filename declara — usado somente para o
265
+ * aviso de mismatch (o magic byte é a fonte de verdade). Retorna `undefined`
266
+ * para extensões ausentes ou não mapeadas (nesse caso não há mismatch a avisar).
267
+ *
268
+ * @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
269
+ * @returns O MIME declarado pela extensão, ou `undefined`.
270
+ * @example
271
+ * mimeForExtension('photo.PNG'); // 'image/png'
272
+ * mimeForExtension('report.pdf'); // 'application/pdf'
273
+ * mimeForExtension('noext'); // undefined
274
+ */
275
+ export function mimeForExtension(filename) {
276
+ const dot = filename.lastIndexOf('.');
277
+ if (dot < 0 || dot === filename.length - 1) {
278
+ return undefined;
279
+ }
280
+ const ext = filename.slice(dot + 1).toLowerCase();
281
+ return EXTENSION_MIME[ext];
282
+ }