@memoring/cli 0.13.0 → 0.15.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.
@@ -0,0 +1,632 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { PDFDocument } from 'pdf-lib';
5
+ import { apiFetch, apiFetchBytes, ApiError } from '../api.js';
6
+ import { CliError } from '../output.js';
7
+ import { dim, fail, green, printJson, printTable, stringFlag, yellow } from './shared.js';
8
+ /**
9
+ * Rascunhos do caderno manuscrito (proxy Drive do backend — fase P2): a CLI
10
+ * lista/baixa os `.scratch.json` + o PDF de fundo (assets dedupados por hash)
11
+ * e recorta o PDF por faixa de páginas antes da geração de flashcards. A CLI
12
+ * NÃO inicia o OAuth do Google — isso é feito pelo app; desconectado, o proxy
13
+ * devolve 409 e o comando orienta o usuário a conectar pelo app.
14
+ */
15
+ /** Estimativa de tokens de prompt por página densa de PDF (medido em prod 2026-10, espelha o app). */
16
+ export const TOKENS_PER_PAGE_ESTIMATE = 2700;
17
+ /** Acima de ~30 páginas, o `extract` exige --all explícito ou um recorte --pages (forcing function). */
18
+ export const EXTRACT_FORCE_ALL_MIN_PAGES = 30;
19
+ /** "113,4 mil tokens" (espelha o formatTokens do app); abaixo de 1 mil, o número cru. */
20
+ export function formatTokens(total) {
21
+ if (total < 1000) {
22
+ return `${total} tokens`;
23
+ }
24
+ const rounded = total < 100_000 ? Math.round(total / 100) / 10 : Math.round(total / 1000);
25
+ return `${String(rounded).replace('.', ',')} mil tokens`;
26
+ }
27
+ /** Nome de arquivo: minúsculo, sem acentos, não-alfanumérico vira hífen, teto de 60 chars. */
28
+ export function slugifyTitle(title) {
29
+ const slug = title
30
+ .normalize('NFD')
31
+ .replace(/[\u0300-\u036f]/g, '')
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '-')
34
+ .replace(/^-+|-+$/g, '')
35
+ .slice(0, 60)
36
+ .replace(/-+$/g, '');
37
+ return slug || 'rascunho';
38
+ }
39
+ /**
40
+ * Comparação de caminhos: NFD, sem acento, minúsculo, espaços colapsados e
41
+ * aspas externas removidas — o usuário digita case/acentos/espaços livres.
42
+ */
43
+ function normalizePathText(value) {
44
+ return value
45
+ .normalize('NFD')
46
+ .replace(/[\u0300-\u036f]/g, '')
47
+ .toLowerCase()
48
+ .replace(/\s+/g, ' ')
49
+ .trim()
50
+ .replace(/^["']+|["']+$/g, '');
51
+ }
52
+ /** "Aula 02" ou "Pasta/Aula 02" (ou com backslash) → segmentos normalizados e não-vazios. */
53
+ function splitPathArg(raw) {
54
+ return raw
55
+ .split(/[\\/]+/)
56
+ .map(normalizePathText)
57
+ .filter((seg) => seg !== '');
58
+ }
59
+ /** Nomes da cadeia de pastas (raiz → folha); tolera ciclo/pasta órfã sem travar. */
60
+ function folderBreadcrumb(folderId, folderMap) {
61
+ const names = [];
62
+ const seen = new Set();
63
+ let current = folderId ?? null;
64
+ while (current !== null && !seen.has(current)) {
65
+ seen.add(current);
66
+ const folder = folderMap.get(current);
67
+ if (!folder) {
68
+ break; // pasta órfã (apagada/offline) — para no que achou
69
+ }
70
+ names.unshift(folder.name);
71
+ current = folder.parentId;
72
+ }
73
+ return names;
74
+ }
75
+ /**
76
+ * Caminho legível do documento na biblioteca: breadcrumb da pasta + título
77
+ * (cada documento é pasta-folha). CARD_SCRATCH vive fora da árvore, em
78
+ * "Rascunhos de cards/<noteId|título>".
79
+ */
80
+ function docPathSegments(doc, folderMap) {
81
+ if (doc.kind === 'CARD_SCRATCH') {
82
+ return ['Rascunhos de cards', String(doc.linkedNoteId ?? doc.title)];
83
+ }
84
+ return [...folderBreadcrumb(doc.folderId, folderMap), doc.title];
85
+ }
86
+ function docPath(doc, folderMap) {
87
+ return docPathSegments(doc, folderMap).join('/');
88
+ }
89
+ /**
90
+ * Resolve o argumento do `download` como caminho completo da biblioteca.
91
+ * Só aceita correspondência exata (normalizada) — sem chute: não encontrado
92
+ * ou ambíguo vira erro acionável listando os candidatos.
93
+ */
94
+ function resolveDocByPath(raw, documents, folderMap) {
95
+ const wanted = splitPathArg(raw);
96
+ if (wanted.length === 0) {
97
+ fail('caminho vazio — informe o caminho completo do rascunho (veja `memoring scratch list`).');
98
+ }
99
+ const wantedKey = wanted.join('/');
100
+ const byPath = new Map();
101
+ for (const doc of documents) {
102
+ const key = docPathSegments(doc, folderMap).map(normalizePathText).join('/');
103
+ const list = byPath.get(key) ?? [];
104
+ list.push(doc);
105
+ byPath.set(key, list);
106
+ }
107
+ const exact = byPath.get(wantedKey) ?? [];
108
+ if (exact.length === 1) {
109
+ return exact[0];
110
+ }
111
+ if (exact.length > 1) {
112
+ const candidates = exact
113
+ .map((d) => ` - ${docPath(d, folderMap)} (driveFileId ${d.driveFileId})`)
114
+ .join('\n');
115
+ fail(`o caminho ${JSON.stringify(raw)} é ambíguo — ${exact.length} rascunhos usam esse caminho:\n` +
116
+ `${candidates}\nrefine o caminho (a pasta) ou use o driveFileId.`);
117
+ }
118
+ const folderHits = [...folderMap.values()].filter((f) => folderBreadcrumb(f.id, folderMap).map(normalizePathText).join('/') === wantedKey);
119
+ if (folderHits.length > 0) {
120
+ const children = documents
121
+ .filter((d) => d.kind !== 'CARD_SCRATCH' &&
122
+ folderBreadcrumb(d.folderId, folderMap).map(normalizePathText).join('/') === wantedKey)
123
+ .map((d) => ` - ${docPath(d, folderMap)} (driveFileId ${d.driveFileId})`);
124
+ fail(`o caminho ${JSON.stringify(raw)} é uma pasta da biblioteca, não um rascunho.` +
125
+ (children.length > 0
126
+ ? `\ndocumentos diretamente nela:\n${children.join('\n')}`
127
+ : '\n(nenhum documento diretamente nela)') +
128
+ '\nbaixe pelo caminho completo do documento (veja `memoring scratch list`).');
129
+ }
130
+ const suffix = documents.filter((d) => {
131
+ const segs = docPathSegments(d, folderMap).map(normalizePathText);
132
+ return segs.length >= wanted.length && segs.slice(segs.length - wanted.length).join('/') === wantedKey;
133
+ });
134
+ const candidateLines = suffix
135
+ .slice(0, 10)
136
+ .map((d) => ` - ${docPath(d, folderMap)}`)
137
+ .join('\n');
138
+ fail(`nenhum rascunho bate com ${JSON.stringify(raw)} — nem como caminho completo nem como driveFileId.` +
139
+ (candidateLines ? `\ncandidatos com esse final de caminho:\n${candidateLines}` : '') +
140
+ '\nconfira os caminhos com `memoring scratch list` (use --all para incluir rascunhos de card).');
141
+ }
142
+ /** Nome local do PDF: basename legível sanitizado (slug sem acento, extensão .pdf preservada). */
143
+ function sanitizePdfFileName(name, fallback) {
144
+ if (typeof name !== 'string' || name.trim() === '') {
145
+ return fallback;
146
+ }
147
+ const base = name
148
+ .trim()
149
+ .replace(/\\/g, '/')
150
+ .split('/')
151
+ .pop()
152
+ .replace(/\.pdf$/i, '');
153
+ const slug = base
154
+ .normalize('NFD')
155
+ .replace(/[\u0300-\u036f]/g, '')
156
+ .toLowerCase()
157
+ .replace(/[^a-z0-9]+/g, '-')
158
+ .replace(/^-+|-+$/g, '')
159
+ .slice(0, 60)
160
+ .replace(/-+$/g, '');
161
+ return slug === '' ? fallback : `${slug}.pdf`;
162
+ }
163
+ /** "A-B" / "A" (aceita hífen e travessão) → faixa 1-based validada contra o pageCount. */
164
+ export function parsePageRange(raw, pageCount) {
165
+ const match = /^(\d+)(?:\s*[-–—]\s*(\d+))?$/.exec(raw.trim());
166
+ if (!match) {
167
+ fail(`--pages inválido: use "A-B" (ex.: 20-35) ou "A" (página única) — veio ${JSON.stringify(raw)}.`);
168
+ }
169
+ const from = Number(match[1]);
170
+ const to = match[2] !== undefined ? Number(match[2]) : from;
171
+ if (from < 1) {
172
+ fail(`a faixa começa na página ${from} — as páginas são numeradas de 1 a ${pageCount}.`);
173
+ }
174
+ if (from > pageCount) {
175
+ fail(`página inicial ${from} além do fim do PDF — o arquivo tem só ${pageCount} página(s).`);
176
+ }
177
+ if (to < from) {
178
+ fail(`a página final (${to}) deve ser maior ou igual à inicial (${from}).`);
179
+ }
180
+ return { from, to: Math.min(to, pageCount), clampedTo: to > pageCount };
181
+ }
182
+ function fmtUpdated(iso) {
183
+ const d = new Date(iso);
184
+ if (Number.isNaN(d.getTime())) {
185
+ return iso;
186
+ }
187
+ const p = (n) => String(n).padStart(2, '0');
188
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
189
+ }
190
+ /** Erros do proxy Drive em mensagens acionáveis. */
191
+ function rethrowDownloadError(e, what) {
192
+ if (e instanceof ApiError) {
193
+ if (e.status === 409) {
194
+ fail(`o Google Drive não está conectado à sua conta (409 ${e.body}) — conecte pelo app ` +
195
+ '(Caderno → vincular Drive) e tente de novo. A CLI não inicia o OAuth do Google.');
196
+ }
197
+ if (e.status === 404) {
198
+ fail(`${what}: arquivo não encontrado no Drive (404 ${e.body}) — confira o id com \`memoring scratch list\`.`);
199
+ }
200
+ fail(`falha ao ${what} (${e.status}): ${e.body}`);
201
+ }
202
+ throw e;
203
+ }
204
+ async function listScratchDocuments() {
205
+ const res = (await apiFetch('/handwriting/documents'));
206
+ return Array.isArray(res?.documents) ? res.documents : [];
207
+ }
208
+ async function listScratchFolders() {
209
+ try {
210
+ const res = (await apiFetch('/handwriting/folders'));
211
+ return Array.isArray(res?.folders) ? res.folders : [];
212
+ }
213
+ catch (e) {
214
+ // backend sem V233: o caminho degrada para "só o título" em vez de derrubar o comando
215
+ if (e instanceof ApiError && e.status === 404) {
216
+ return [];
217
+ }
218
+ throw e;
219
+ }
220
+ }
221
+ /** Documentos + pastas da biblioteca (as duas âncoras do caminho legível). */
222
+ async function fetchScratchLibrary() {
223
+ const [documents, folders] = await Promise.all([listScratchDocuments(), listScratchFolders()]);
224
+ return { documents, folderMap: new Map(folders.map((f) => [f.id, f])) };
225
+ }
226
+ async function scratchList(parsed) {
227
+ const includeAll = parsed.flags.all === true;
228
+ const { documents, folderMap } = await fetchScratchLibrary();
229
+ const docs = documents
230
+ .filter((d) => includeAll || d.kind === 'FREE_NOTEBOOK')
231
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
232
+ .map((d) => ({ ...d, path: docPath(d, folderMap) }));
233
+ if (parsed.flags.json) {
234
+ printJson(docs);
235
+ return;
236
+ }
237
+ if (docs.length === 0) {
238
+ process.stdout.write(includeAll ? 'Nenhum rascunho encontrado.\n' : 'Nenhum caderno encontrado (use --all para incluir rascunhos de card).\n');
239
+ return;
240
+ }
241
+ const columns = [
242
+ { key: 'path', label: 'CAMINHO' },
243
+ ...(includeAll ? [{ key: 'kind', label: 'TIPO' }] : []),
244
+ { key: 'updatedAt', label: 'ATUALIZADO' },
245
+ { key: 'driveFileId', label: 'DRIVE FILE ID' },
246
+ ];
247
+ printTable(docs.map((d) => ({
248
+ path: d.path,
249
+ ...(includeAll ? { kind: d.kind } : {}),
250
+ updatedAt: fmtUpdated(d.updatedAt),
251
+ driveFileId: d.driveFileId,
252
+ })), columns);
253
+ process.stdout.write(`\n${docs.length} rascunho(s) — baixe com \`memoring scratch download <caminho>\` ` +
254
+ `(ex.: \`memoring scratch download "${docs[0].path}"\`).\n`);
255
+ }
256
+ async function scratchDownload(parsed) {
257
+ const targetArg = parsed._[1];
258
+ if (!targetArg) {
259
+ fail('informe o caminho ou o driveFileId: memoring scratch download <caminho|driveFileId> [--out <dir>] ' +
260
+ '(veja `memoring scratch list`).');
261
+ }
262
+ const outDir = resolve(stringFlag(parsed.flags, 'out') ?? '.');
263
+ const metadataOnly = parsed.flags['metadata-only'] === true;
264
+ // Âncora local (título/kind/caminho) — best-effort: resolve caminho → driveFileId
265
+ // quando a biblioteca responde; sem ela, o argumento é tratado como driveFileId
266
+ // (comportamento das versões anteriores).
267
+ let anchor;
268
+ let anchorPath = null;
269
+ try {
270
+ const { documents, folderMap } = await fetchScratchLibrary();
271
+ anchor = documents.find((d) => d.driveFileId === targetArg);
272
+ if (!anchor) {
273
+ anchor = resolveDocByPath(targetArg, documents, folderMap);
274
+ }
275
+ anchorPath = docPath(anchor, folderMap);
276
+ }
277
+ catch (e) {
278
+ if (e instanceof CliError) {
279
+ throw e;
280
+ }
281
+ process.stderr.write(dim('aviso: não consegui carregar a biblioteca de rascunhos — tratando o argumento como driveFileId.\n'));
282
+ }
283
+ const driveFileId = anchor?.driveFileId ?? targetArg;
284
+ let jsonBytes;
285
+ try {
286
+ jsonBytes = (await apiFetchBytes(`/handwriting/files/${encodeURIComponent(driveFileId)}`)).body;
287
+ }
288
+ catch (e) {
289
+ rethrowDownloadError(e, 'baixar o rascunho');
290
+ }
291
+ let doc;
292
+ try {
293
+ doc = JSON.parse(jsonBytes.toString('utf8'));
294
+ }
295
+ catch {
296
+ fail('o arquivo baixado não é um .scratch.json válido (JSON ilegível).');
297
+ }
298
+ const title = (typeof doc.title === 'string' && doc.title.trim() !== '' && doc.title) ||
299
+ anchor?.title ||
300
+ driveFileId;
301
+ const pdf = (typeof doc.pdf === 'object' && doc.pdf !== null ? doc.pdf : undefined);
302
+ const pdfDriveFileId = typeof pdf?.driveFileId === 'string' && pdf.driveFileId.trim() !== '' ? pdf.driveFileId : undefined;
303
+ const pdfAssetHash = typeof pdf?.assetHash === 'string' && pdf.assetHash.trim() !== '' ? pdf.assetHash : undefined;
304
+ const pdfPageCount = typeof pdf?.pageCount === 'number' && pdf.pageCount > 0 ? pdf.pageCount : undefined;
305
+ const hasPdf = pdfDriveFileId !== undefined || (pdfAssetHash !== undefined && pdfPageCount !== undefined);
306
+ mkdirSync(outDir, { recursive: true });
307
+ const jsonPath = join(outDir, `${slugifyTitle(title)}.scratch.json`);
308
+ writeFileSync(jsonPath, jsonBytes);
309
+ const files = { scratchJson: jsonPath };
310
+ let pdfInfo = null;
311
+ if (hasPdf && !metadataOnly) {
312
+ let pdfBytes;
313
+ let pdfFileId;
314
+ if (pdfDriveFileId) {
315
+ try {
316
+ pdfBytes = (await apiFetchBytes(`/handwriting/files/${encodeURIComponent(pdfDriveFileId)}`)).body;
317
+ pdfFileId = pdfDriveFileId;
318
+ }
319
+ catch (e) {
320
+ // 404 no arquivo do PDF → fallback pro asset dedupado (rascunhos antigos);
321
+ // qualquer outro erro (409 desconectado, 5xx) é fatal.
322
+ if (!(e instanceof ApiError) || e.status !== 404) {
323
+ rethrowDownloadError(e, 'baixar o PDF de fundo');
324
+ }
325
+ }
326
+ }
327
+ if (pdfBytes === undefined && pdfAssetHash) {
328
+ const assetName = `${pdfAssetHash}.pdf`;
329
+ let assetFile;
330
+ try {
331
+ const res = (await apiFetch(`/handwriting/files?folder=assets&name=${encodeURIComponent(assetName)}`));
332
+ assetFile = res?.files?.[0];
333
+ }
334
+ catch (e) {
335
+ rethrowDownloadError(e, 'localizar o PDF de fundo');
336
+ }
337
+ if (assetFile) {
338
+ try {
339
+ pdfBytes = (await apiFetchBytes(`/handwriting/files/${encodeURIComponent(assetFile.fileId)}`)).body;
340
+ pdfFileId = assetFile.fileId;
341
+ }
342
+ catch (e) {
343
+ rethrowDownloadError(e, 'baixar o PDF de fundo');
344
+ }
345
+ }
346
+ }
347
+ if (pdfBytes === undefined || pdfFileId === undefined) {
348
+ const detail = pdfDriveFileId && !pdfAssetHash
349
+ ? `o arquivo do PDF (driveFileId ${pdfDriveFileId}) não foi encontrado no Drive`
350
+ : `o asset ${pdfAssetHash}.pdf não foi encontrado na pasta assets`;
351
+ process.stderr.write(yellow(`aviso: ${detail} — só o .scratch.json foi salvo.\n`));
352
+ }
353
+ else {
354
+ const pdfName = sanitizePdfFileName(pdf?.name, pdfAssetHash ? `${pdfAssetHash}.pdf` : `${slugifyTitle(title)}.pdf`);
355
+ const pdfPath = join(outDir, pdfName);
356
+ writeFileSync(pdfPath, pdfBytes);
357
+ files.pdf = pdfPath;
358
+ pdfInfo = { assetHash: pdfAssetHash ?? null, pageCount: pdfPageCount, fileId: pdfFileId, path: pdfPath };
359
+ }
360
+ }
361
+ if (parsed.flags.json) {
362
+ printJson({
363
+ title,
364
+ kind: anchor?.kind ?? null,
365
+ path: anchorPath,
366
+ driveFileId,
367
+ files,
368
+ pdfMetadata: hasPdf
369
+ ? {
370
+ driveFileId: pdfDriveFileId ?? null,
371
+ assetHash: pdfAssetHash ?? null,
372
+ pageCount: pdfPageCount ?? null,
373
+ name: typeof pdf?.name === 'string' ? pdf.name : null,
374
+ }
375
+ : null,
376
+ pdf: pdfInfo
377
+ ? {
378
+ assetHash: pdfInfo.assetHash,
379
+ pageCount: pdfInfo.pageCount ?? null,
380
+ tokensEstimate: pdfInfo.pageCount !== undefined ? pdfInfo.pageCount * TOKENS_PER_PAGE_ESTIMATE : null,
381
+ fileId: pdfInfo.fileId,
382
+ }
383
+ : null,
384
+ });
385
+ return;
386
+ }
387
+ process.stdout.write(green(`Rascunho baixado: "${title}"${anchor?.kind ? ` [${anchor.kind}]` : ''}\n`));
388
+ process.stdout.write(` JSON: ${jsonPath}\n`);
389
+ if (pdfInfo) {
390
+ if (pdfInfo.pageCount !== undefined) {
391
+ process.stdout.write(` PDF: ${pdfInfo.path} (${pdfInfo.pageCount} ${pdfInfo.pageCount === 1 ? 'página' : 'páginas'}, ` +
392
+ `≈${formatTokens(pdfInfo.pageCount * TOKENS_PER_PAGE_ESTIMATE)})\n`);
393
+ if (pdfInfo.pageCount > EXTRACT_FORCE_ALL_MIN_PAGES) {
394
+ process.stdout.write(yellow(` PDF com ${pdfInfo.pageCount} páginas (≈${formatTokens(pdfInfo.pageCount * TOKENS_PER_PAGE_ESTIMATE)}). ` +
395
+ `Para gerar por faixas: memoring scratch extract ${pdfInfo.path} --pages A-B\n`));
396
+ }
397
+ }
398
+ else {
399
+ process.stdout.write(` PDF: ${pdfInfo.path} (número de páginas desconhecido)\n`);
400
+ }
401
+ }
402
+ else if (metadataOnly && hasPdf) {
403
+ process.stdout.write(` PDF (não baixado): ${pdfDriveFileId ?? `asset ${pdfAssetHash}`} — ` +
404
+ `${pdfPageCount ?? '?'} página(s).\n`);
405
+ }
406
+ else {
407
+ process.stderr.write(yellow('aviso: este rascunho não tem PDF de fundo — só o .scratch.json foi salvo. Gere a partir do conteúdo dele.\n'));
408
+ }
409
+ if (!metadataOnly) {
410
+ process.stdout.write(dim('Próximo passo: memoring scratch extract <arquivo.pdf> --pages A-B (se necessário) → stage create.\n'));
411
+ }
412
+ }
413
+ async function scratchExtract(parsed) {
414
+ const fileArg = parsed._[1];
415
+ if (!fileArg) {
416
+ fail('informe o PDF: memoring scratch extract <arquivo.pdf> [--pages A-B] [--all] [--text] [--out <alvo>]');
417
+ }
418
+ if (!existsSync(fileArg)) {
419
+ fail(`arquivo não encontrado: ${fileArg}`);
420
+ }
421
+ const wantText = parsed.flags.text === true;
422
+ // pageCount vem do pdf-lib (recorte) ou do pdfjs (--text) — a faixa e a
423
+ // forcing function são idênticas nos dois caminhos.
424
+ let pageCount;
425
+ let srcDoc = null;
426
+ let textDoc = null;
427
+ if (wantText) {
428
+ textDoc = await loadPdfjsDocument(fileArg);
429
+ pageCount = textDoc.numPages;
430
+ }
431
+ else {
432
+ try {
433
+ srcDoc = await PDFDocument.load(readFileSync(fileArg));
434
+ }
435
+ catch (e) {
436
+ fail(`não consegui ler o PDF (${fileArg}): ${e.message} — o arquivo é um PDF válido?`);
437
+ }
438
+ pageCount = srcDoc.getPageCount();
439
+ }
440
+ const pagesRaw = stringFlag(parsed.flags, 'pages');
441
+ let range;
442
+ if (pagesRaw !== undefined) {
443
+ range = parsePageRange(pagesRaw, pageCount);
444
+ if (range.clampedTo) {
445
+ process.stderr.write(yellow(`aviso: o PDF tem ${pageCount} página(s) — a faixa foi limitada até ${range.to}.\n`));
446
+ }
447
+ }
448
+ else {
449
+ if (pageCount > EXTRACT_FORCE_ALL_MIN_PAGES && parsed.flags.all !== true) {
450
+ fail(`este PDF tem ${pageCount} páginas (≈${formatTokens(pageCount * TOKENS_PER_PAGE_ESTIMATE)}) — confirme com --all ` +
451
+ 'ou recorte com --pages A-B (gerar de um PDF inteiro dessa grossura estoura o contexto do agente).');
452
+ }
453
+ range = { from: 1, to: pageCount, clampedTo: false };
454
+ }
455
+ if (wantText) {
456
+ await scratchExtractText(parsed, fileArg, textDoc, range, pageCount);
457
+ return;
458
+ }
459
+ const indices = [];
460
+ for (let i = range.from - 1; i <= range.to - 1; i++) {
461
+ indices.push(i);
462
+ }
463
+ const outDoc = await PDFDocument.create();
464
+ const copied = await outDoc.copyPages(srcDoc, indices);
465
+ for (const page of copied) {
466
+ outDoc.addPage(page);
467
+ }
468
+ const outBytes = await outDoc.save();
469
+ const explicitOut = stringFlag(parsed.flags, 'out');
470
+ const outPath = explicitOut
471
+ ? resolve(explicitOut)
472
+ : resolve(dirname(fileArg), `${basename(fileArg).replace(/\.pdf$/i, '')}-p${range.from}-${range.to}.pdf`);
473
+ mkdirSync(dirname(outPath), { recursive: true });
474
+ writeFileSync(outPath, outBytes);
475
+ const extractedPages = range.to - range.from + 1;
476
+ const tokens = extractedPages * TOKENS_PER_PAGE_ESTIMATE;
477
+ if (parsed.flags.json) {
478
+ printJson({
479
+ input: fileArg,
480
+ output: outPath,
481
+ pageCount,
482
+ range: { from: range.from, to: range.to },
483
+ extractedPages,
484
+ tokensEstimate: tokens,
485
+ });
486
+ return;
487
+ }
488
+ process.stdout.write(green(`Recorte salvo em ${outPath}\n`) +
489
+ ` faixa ${range.from}-${range.to} de ${pageCount} página(s) → ${extractedPages} página(s), ` +
490
+ `≈${formatTokens(tokens)}\n` +
491
+ dim('Gere os flashcards a partir DESTE recorte (não do PDF inteiro).\n'));
492
+ }
493
+ /**
494
+ * Abre o PDF com pdfjs-dist (build legacy — a única build compatível com Node)
495
+ * para extração de texto. O worker não existe em Node: o pdfjs cai sozinho no
496
+ * "fake worker" de main thread quando `Worker` está indefinido (comportamento
497
+ * documentado da versão pinada). `standardFontDataUrl`/`cMapUrl` apontam pros
498
+ * dados bundled do próprio pacote — sem eles PDFs com fontes standard
499
+ * não-embedded (ex.: os que o pdf-lib gera) avisam no stderr.
500
+ */
501
+ async function loadPdfjsDocument(fileArg) {
502
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
503
+ const require = createRequire(import.meta.url);
504
+ const packageDir = dirname(require.resolve('pdfjs-dist/package.json'));
505
+ try {
506
+ const loadingTask = pdfjs.getDocument({
507
+ data: new Uint8Array(readFileSync(fileArg)),
508
+ standardFontDataUrl: `${packageDir}/standard_fonts/`,
509
+ cMapUrl: `${packageDir}/cmaps/`,
510
+ cMapPacked: true,
511
+ });
512
+ return await loadingTask.promise;
513
+ }
514
+ catch (e) {
515
+ fail(`não consegui ler o PDF (${fileArg}): ${e.message} — o arquivo é um PDF válido?`);
516
+ }
517
+ }
518
+ /** Texto legível de uma página: itens agrupados por linha (transform[5] ≈ y) e
519
+ * ordenados por x — o content stream não preserva quebras, só posições. */
520
+ export function pageTextFromContent(content) {
521
+ const lines = [];
522
+ for (const item of content.items) {
523
+ if (!('str' in item) || item.str === '')
524
+ continue;
525
+ const x = Number(item.transform[4] ?? 0);
526
+ const y = Number(item.transform[5] ?? 0);
527
+ const line = lines.find((candidate) => Math.abs(candidate.y - y) < 2);
528
+ if (line) {
529
+ line.parts.push({ x, str: item.str });
530
+ }
531
+ else {
532
+ lines.push({ y, parts: [{ x, str: item.str }] });
533
+ }
534
+ }
535
+ return lines
536
+ .sort((a, b) => b.y - a.y)
537
+ .map((line) => line.parts
538
+ .sort((a, b) => a.x - b.x)
539
+ .map((part) => part.str)
540
+ .join(' '))
541
+ .join('\n')
542
+ .replace(/\n{3,}/g, '\n\n')
543
+ .trim();
544
+ }
545
+ /** `--text`: imprime o texto da faixa no stdout (ou grava em --out), páginas
546
+ * separadas por "--- Página N ---". Sem --out o stdout é só o texto (pipeável)
547
+ * e o resumo vai pro stderr. */
548
+ async function scratchExtractText(parsed, fileArg, doc, range, pageCount) {
549
+ const chunks = [];
550
+ for (let n = range.from; n <= range.to; n++) {
551
+ const page = await doc.getPage(n);
552
+ const content = await page.getTextContent();
553
+ chunks.push(`--- Página ${n} ---\n${pageTextFromContent(content)}`);
554
+ }
555
+ const text = `${chunks.join('\n\n')}\n`;
556
+ const extractedPages = range.to - range.from + 1;
557
+ const explicitOut = stringFlag(parsed.flags, 'out');
558
+ if (parsed.flags.json) {
559
+ printJson({
560
+ input: fileArg,
561
+ output: explicitOut ? resolve(explicitOut) : null,
562
+ pageCount,
563
+ range: { from: range.from, to: range.to },
564
+ extractedPages,
565
+ text,
566
+ });
567
+ return;
568
+ }
569
+ if (explicitOut) {
570
+ const outPath = resolve(explicitOut);
571
+ mkdirSync(dirname(outPath), { recursive: true });
572
+ writeFileSync(outPath, text);
573
+ process.stdout.write(green(`Texto extraído em ${outPath}\n`) +
574
+ ` faixa ${range.from}-${range.to} de ${pageCount} página(s) → ${extractedPages} página(s)\n`);
575
+ return;
576
+ }
577
+ process.stdout.write(text);
578
+ process.stderr.write(dim(`scratch extract --text: faixa ${range.from}-${range.to} de ${pageCount} página(s) → ` +
579
+ `${extractedPages} página(s), ${text.length} chars.\n`));
580
+ }
581
+ const HELP = `Uso: memoring scratch <list|download|extract> [args] [--json]
582
+
583
+ Rascunhos do caderno manuscrito no Google Drive (via proxy do backend — exige
584
+ o Drive conectado PELO APP; a CLI não inicia o OAuth do Google).
585
+
586
+ list [--all] lista os rascunhos pelo CAMINHO da biblioteca
587
+ (pasta/pasta/título), com atualização e driveFileId;
588
+ default: só cadernos (FREE_NOTEBOOK); --all inclui
589
+ rascunhos de card (CARD_SCRATCH, prefixo
590
+ "Rascunhos de cards/<noteId|título>"); ordenado por
591
+ atualização, mais novo primeiro
592
+ download <caminho|id> aceita o caminho completo mostrado no list (acentos,
593
+ [--out <dir>] maiúsculas e espaços livres) ou o driveFileId; baixa
594
+ [--metadata-only] o .scratch.json (salvo em <out>/<título-slug>.scratch.json)
595
+ e, se o rascunho tem PDF de fundo, o PDF junto — pelo
596
+ driveFileId do próprio arquivo quando disponível, senão
597
+ resolvendo o asset <hash>.pdf na pasta assets (nome
598
+ local: pdf.name legível sanitizado). --metadata-only baixa
599
+ só o JSON e informa os metadados do PDF, sem o binário.
600
+ Caminho não encontrado ou ambíguo = erro com os candidatos
601
+ — a CLI nunca escolhe por você
602
+ extract <arquivo.pdf> recorta a faixa de páginas num sub-PDF (pdf-lib, sem deps nativas)
603
+ [--pages A-B] "20-35" ou "20" (página única); to além do fim é limitado ao total
604
+ [--all] PDF inteiro — obrigatório explicitar quando o PDF tem mais de
605
+ ~30 páginas (≈2,7 mil tokens/página: estourar o contexto do
606
+ agente de propósito exige confirmação)
607
+ [--out <alvo>] default: <nome>-pA-B.pdf ao lado do arquivo de origem
608
+ [--text] extrai o TEXTO da faixa (pdfjs-dist) no stdout, páginas
609
+ separadas por "--- Página N ---" — com --out grava em arquivo.
610
+ Mesma forcing function (> ~30 páginas sem --pages exige --all)
611
+
612
+ Estimativa de contexto: página densa de PDF ≈ 2.700 tokens. Acima de ~60 páginas,
613
+ SEMPRE recorte antes de gerar flashcards.`;
614
+ export async function scratchCommand(parsed) {
615
+ const [sub] = parsed._;
616
+ switch (sub) {
617
+ case 'list':
618
+ await scratchList(parsed);
619
+ return;
620
+ case 'download':
621
+ await scratchDownload(parsed);
622
+ return;
623
+ case 'extract':
624
+ await scratchExtract(parsed);
625
+ return;
626
+ default:
627
+ process.stdout.write(HELP + '\n');
628
+ if (sub) {
629
+ process.exitCode = 1;
630
+ }
631
+ }
632
+ }