@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.
- package/README.md +162 -9
- package/dist/api.js +90 -4
- package/dist/args.js +14 -1
- package/dist/auth.js +1 -1
- package/dist/commands/batch.js +327 -0
- package/dist/commands/materialReferences.js +359 -0
- package/dist/commands/plan-analyze.js +256 -0
- package/dist/commands/plan-diagnostics.js +65 -0
- package/dist/commands/plan-exams.js +122 -0
- package/dist/commands/plan-goal.js +131 -0
- package/dist/commands/plan-shared.js +133 -0
- package/dist/commands/plan.js +219 -0
- package/dist/commands/planDrafts.js +162 -0
- package/dist/commands/scratch.js +632 -0
- package/dist/commands/stage.js +215 -77
- package/dist/config.js +2 -0
- package/dist/main.js +43 -2
- package/dist/pdf/batchValidation.js +72 -0
- package/dist/pdf/documentCache.js +68 -0
- package/dist/pdf/quoteLocate.js +181 -0
- package/package.json +8 -2
- package/skills/memoring-cli/SKILL.md +57 -7
|
@@ -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
|
+
}
|