@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,327 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { apiFetch } from '../api.js';
7
+ import { listDecks } from './deck.js';
8
+ import { fail, green, printJson, yellow } from './shared.js';
9
+ const MAIN_PATH = fileURLToPath(new URL('../main.js', import.meta.url));
10
+ function parseJsonFile(filePath) {
11
+ return JSON.parse(readFileSync(filePath, 'utf8'));
12
+ }
13
+ function batchItems(filePath) {
14
+ const payload = parseJsonFile(filePath);
15
+ return Array.isArray(payload) ? payload : payload.items;
16
+ }
17
+ function sha256(value) {
18
+ return createHash('sha256').update(value).digest('hex');
19
+ }
20
+ function loadPlan(planPath) {
21
+ const raw = readFileSync(planPath, 'utf8');
22
+ const plan = JSON.parse(raw);
23
+ if (plan.version !== 1 || !Array.isArray(plan.activities) || plan.activities.length === 0) {
24
+ fail('manifesto inválido: esperado version=1 e activities não vazio.');
25
+ }
26
+ if (typeof plan.pdfMap !== 'object' || plan.pdfMap === null || Array.isArray(plan.pdfMap)) {
27
+ fail('manifesto inválido: pdfMap deve ser um objeto driveFileId → PDF local.');
28
+ }
29
+ const base = dirname(resolve(planPath));
30
+ const ids = new Set();
31
+ for (const [index, activity] of plan.activities.entries()) {
32
+ const where = `atividade #${index + 1}`;
33
+ if (!activity.id || ids.has(activity.id))
34
+ fail(`${where}: id ausente ou duplicado (${activity.id}).`);
35
+ ids.add(activity.id);
36
+ if (!activity.batchFile || !activity.deckName)
37
+ fail(`${where} (${activity.id}): batchFile/deckName obrigatórios.`);
38
+ activity.batchFile = resolve(base, activity.batchFile);
39
+ if (activity.generationFile)
40
+ activity.generationFile = resolve(base, activity.generationFile);
41
+ if (!Array.isArray(activity.expectedReferences))
42
+ activity.expectedReferences = [];
43
+ }
44
+ for (const [id, file] of Object.entries(plan.pdfMap)) {
45
+ if (typeof file !== 'string' || file.trim() === '')
46
+ fail(`pdfMap.${id}: caminho inválido.`);
47
+ plan.pdfMap[id] = resolve(base, file);
48
+ }
49
+ return { plan, raw };
50
+ }
51
+ function strictDeck(decks, ref, activityId) {
52
+ const numeric = Number(ref);
53
+ const matches = Number.isInteger(numeric) && numeric > 0
54
+ ? decks.filter((deck) => deck.id === numeric)
55
+ : decks.filter((deck) => deck.name.trim().toLocaleLowerCase('pt-BR') === ref.trim().toLocaleLowerCase('pt-BR'));
56
+ if (matches.length === 0) {
57
+ throw new Error(`${activityId}: baralho ${ref} não existe; crie-o explicitamente antes do batch.`);
58
+ }
59
+ if (matches.length > 1) {
60
+ throw new Error(`${activityId}: baralho ${ref} é ambíguo (${matches.map((deck) => `#${deck.id}`).join(', ')}); use o ID.`);
61
+ }
62
+ return matches[0];
63
+ }
64
+ async function runCliJson(args) {
65
+ return new Promise((resolvePromise, reject) => {
66
+ execFile(process.execPath, [MAIN_PATH, ...args, '--json', '--no-color'], {
67
+ env: { ...process.env, MEMORING_NO_UPDATE_CHECK: '1' },
68
+ encoding: 'utf8',
69
+ maxBuffer: 32 * 1024 * 1024,
70
+ }, (error, stdout, stderr) => {
71
+ const code = error && typeof error.code === 'number'
72
+ ? Number(error.code)
73
+ : error ? 1 : 0;
74
+ try {
75
+ resolvePromise({ code, data: JSON.parse(stdout), stdout, stderr });
76
+ }
77
+ catch {
78
+ reject(new Error(`CLI retornou saída inválida (${args.join(' ')}): ${stderr || stdout || error?.message}`));
79
+ }
80
+ });
81
+ });
82
+ }
83
+ function pageRange(raw) {
84
+ const match = /^(\d+)(?:-(\d+))?$/.exec(raw);
85
+ if (!match)
86
+ throw new Error(`faixa de páginas inválida: ${raw}`);
87
+ const from = Number(match[1]);
88
+ const to = Number(match[2] ?? match[1]);
89
+ if (from < 1 || to < from)
90
+ throw new Error(`faixa de páginas inválida: ${raw}`);
91
+ return { from, to };
92
+ }
93
+ function writeJsonAtomic(filePath, value) {
94
+ const temp = `${filePath}.${process.pid}.tmp`;
95
+ writeFileSync(temp, JSON.stringify(value, null, 2));
96
+ renameSync(temp, filePath);
97
+ }
98
+ async function checkPlan(planPath, plan) {
99
+ const errors = [];
100
+ const warnings = [];
101
+ const checked = [];
102
+ const pdfMapFile = `${planPath}.pdf-map.json`;
103
+ writeJsonAtomic(pdfMapFile, plan.pdfMap);
104
+ let decks;
105
+ try {
106
+ decks = await listDecks();
107
+ }
108
+ catch (error) {
109
+ return { valid: false, errors: [`não consegui listar os baralhos: ${error.message}`], warnings, activities: [], pdfMapFile };
110
+ }
111
+ for (const activity of plan.activities) {
112
+ let deck;
113
+ try {
114
+ deck = strictDeck(decks, activity.deckName, activity.id);
115
+ }
116
+ catch (error) {
117
+ errors.push(error.message);
118
+ continue;
119
+ }
120
+ if (!existsSync(activity.batchFile)) {
121
+ errors.push(`${activity.id}: batchFile não encontrado (${activity.batchFile}).`);
122
+ continue;
123
+ }
124
+ if (activity.generationFile && !existsSync(activity.generationFile)) {
125
+ errors.push(`${activity.id}: generationFile não encontrado (${activity.generationFile}).`);
126
+ continue;
127
+ }
128
+ const items = batchItems(activity.batchFile);
129
+ for (const [index, item] of items.entries()) {
130
+ if (item.deckName !== activity.deckName) {
131
+ errors.push(`${activity.id}, item #${index + 1} (${item.title}): deckName ${item.deckName} difere de ${activity.deckName}.`);
132
+ }
133
+ for (const reference of item.materialReferences ?? []) {
134
+ if (activity.expectedReferences.length === 0)
135
+ continue;
136
+ const matches = activity.expectedReferences.some((expected) => {
137
+ if (expected.driveFileId !== reference.driveFileId)
138
+ return false;
139
+ const range = pageRange(expected.pages);
140
+ return reference.page >= range.from && reference.page <= range.to;
141
+ });
142
+ if (!matches) {
143
+ errors.push(`${activity.id}, item #${index + 1} (${item.title}): referência ${reference.driveFileId} página ${reference.page} fora do esperado.`);
144
+ }
145
+ }
146
+ }
147
+ const validation = await runCliJson(['stage', 'validate', '--file', activity.batchFile, '--pdf-map', pdfMapFile]);
148
+ if (!validation.data.valid) {
149
+ errors.push(...(validation.data.errors ?? []).map((error) => `${activity.id}: ${error}`));
150
+ }
151
+ warnings.push(...(validation.data.warnings ?? []).map((warning) => `${activity.id}: ${warning}`));
152
+ checked.push({
153
+ ...activity,
154
+ deckId: deck.id,
155
+ items,
156
+ batchHash: sha256(readFileSync(activity.batchFile, 'utf8')),
157
+ });
158
+ }
159
+ return { valid: errors.length === 0, errors, warnings, activities: checked, pdfMapFile };
160
+ }
161
+ function statePath(planPath) {
162
+ return `${planPath}.state.json`;
163
+ }
164
+ function loadState(planPath, planHash) {
165
+ const path = statePath(planPath);
166
+ if (!existsSync(path))
167
+ return { version: 1, planHash, activities: {} };
168
+ let state;
169
+ try {
170
+ state = parseJsonFile(path);
171
+ }
172
+ catch (error) {
173
+ fail(`state corrompido (${path}): ${error.message}`);
174
+ }
175
+ if (state.version !== 1 || state.planHash !== planHash || typeof state.activities !== 'object') {
176
+ fail(`state incompatível com o manifesto (${path}); revise-o ou use outro manifesto.`);
177
+ }
178
+ return state;
179
+ }
180
+ async function verifyActivities(activities, state) {
181
+ const errors = [];
182
+ const inbox = (await apiFetch('/notes/review-inbox'));
183
+ const byId = new Map(inbox.map((note) => [note.id, note]));
184
+ for (const activity of activities) {
185
+ const current = state.activities[activity.id];
186
+ if (!current) {
187
+ errors.push(`${activity.id}: ainda não foi estagiada.`);
188
+ continue;
189
+ }
190
+ if (current.ids.length !== activity.items.length) {
191
+ errors.push(`${activity.id}: ${current.ids.length} IDs para ${activity.items.length} itens.`);
192
+ continue;
193
+ }
194
+ for (const [index, noteId] of current.ids.entries()) {
195
+ const note = byId.get(noteId);
196
+ if (!note) {
197
+ errors.push(`${activity.id}: nota #${noteId} ausente da caixa de revisão.`);
198
+ continue;
199
+ }
200
+ if (note.deckId !== activity.deckId) {
201
+ errors.push(`${activity.id}/${noteId}: deckId ${note.deckId}, esperado ${activity.deckId}.`);
202
+ }
203
+ const body = (await apiFetch(`/notes/${noteId}/material-references`));
204
+ const expected = activity.items[index].materialReferences ?? [];
205
+ const actual = body.references;
206
+ if (actual.length !== expected.length) {
207
+ errors.push(`${activity.id}/${noteId}: ${actual.length} referências, esperado ${expected.length}.`);
208
+ continue;
209
+ }
210
+ for (const [refIndex, reference] of actual.entries()) {
211
+ const source = expected[refIndex];
212
+ if (reference.driveFileId !== source.driveFileId ||
213
+ reference.pageIndex + 1 !== source.page ||
214
+ (reference.quote ?? undefined) !== source.quote ||
215
+ (reference.contextBefore ?? undefined) !== source.contextBefore ||
216
+ (reference.contextAfter ?? undefined) !== source.contextAfter ||
217
+ JSON.stringify(reference.region ?? null) !== JSON.stringify(source.region ?? null)) {
218
+ errors.push(`${activity.id}/${noteId}: referência #${refIndex + 1} divergente.`);
219
+ }
220
+ }
221
+ }
222
+ if (current.importRunId) {
223
+ const run = (await apiFetch(`/notes/import-runs/${current.importRunId}`));
224
+ const linked = new Set(run.noteIds);
225
+ for (const noteId of current.ids) {
226
+ if (!linked.has(noteId))
227
+ errors.push(`${activity.id}: nota #${noteId} ausente no import run ${current.importRunId}.`);
228
+ }
229
+ }
230
+ }
231
+ return errors;
232
+ }
233
+ function emit(parsed, result, valid) {
234
+ if (parsed.flags.json) {
235
+ printJson(result);
236
+ }
237
+ else {
238
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
239
+ }
240
+ if (!valid)
241
+ process.exitCode = 1;
242
+ }
243
+ export async function batchCommand(parsed) {
244
+ const [sub, planArg] = parsed._;
245
+ if (!sub || !planArg) {
246
+ fail('uso: memoring batch <check|run|status|verify> plan.json [--resume] [--json]');
247
+ }
248
+ const planPath = resolve(planArg);
249
+ if (sub === 'status') {
250
+ const { raw } = loadPlan(planPath);
251
+ const state = loadState(planPath, sha256(raw));
252
+ emit(parsed, state, true);
253
+ return;
254
+ }
255
+ const { plan, raw } = loadPlan(planPath);
256
+ if (sub === 'check') {
257
+ const result = await checkPlan(planPath, plan);
258
+ emit(parsed, result, result.valid);
259
+ return;
260
+ }
261
+ if (sub === 'verify') {
262
+ const checked = await checkPlan(planPath, plan);
263
+ if (!checked.valid) {
264
+ emit(parsed, checked, false);
265
+ return;
266
+ }
267
+ const state = loadState(planPath, sha256(raw));
268
+ const errors = await verifyActivities(checked.activities, state);
269
+ emit(parsed, { valid: errors.length === 0, errors }, errors.length === 0);
270
+ return;
271
+ }
272
+ if (sub !== 'run') {
273
+ fail(`subcomando inválido: ${sub}; use check, run, status ou verify.`);
274
+ }
275
+ const checked = await checkPlan(planPath, plan);
276
+ if (!checked.valid) {
277
+ emit(parsed, checked, false);
278
+ return;
279
+ }
280
+ const state = loadState(planPath, sha256(raw));
281
+ for (const activity of checked.activities) {
282
+ let current = state.activities[activity.id];
283
+ if (current && current.batchHash !== activity.batchHash) {
284
+ fail(`${activity.id}: o lote mudou desde o último envio; revise o state antes de continuar.`);
285
+ }
286
+ if (current?.status === 'verified' && parsed.flags.resume) {
287
+ continue;
288
+ }
289
+ if (!current || current.ids.length !== activity.items.length) {
290
+ const args = ['stage', 'create', '--file', activity.batchFile];
291
+ if (activity.generationFile)
292
+ args.push('--generation-file', activity.generationFile);
293
+ const created = await runCliJson(args);
294
+ if (created.code !== 0) {
295
+ fail(`${activity.id}: stage create falhou: ${created.stderr || created.stdout}`);
296
+ }
297
+ if (!Array.isArray(created.data.ids)) {
298
+ fail(`${activity.id}: stage create não devolveu os IDs esperados.`);
299
+ }
300
+ current = {
301
+ batchHash: activity.batchHash,
302
+ deckId: activity.deckId,
303
+ ids: created.data.ids,
304
+ importRunId: created.data.importRunId ?? null,
305
+ status: 'staged',
306
+ updatedAt: new Date().toISOString(),
307
+ };
308
+ state.activities[activity.id] = current;
309
+ writeJsonAtomic(statePath(planPath), state);
310
+ }
311
+ }
312
+ const errors = await verifyActivities(checked.activities, state);
313
+ if (errors.length === 0) {
314
+ for (const activity of checked.activities) {
315
+ state.activities[activity.id].status = 'verified';
316
+ state.activities[activity.id].updatedAt = new Date().toISOString();
317
+ }
318
+ writeJsonAtomic(statePath(planPath), state);
319
+ }
320
+ emit(parsed, { valid: errors.length === 0, errors, state }, errors.length === 0);
321
+ if (errors.length === 0 && !parsed.flags.json) {
322
+ process.stdout.write(green('Batch enviado e conferido.\n'));
323
+ }
324
+ else if (errors.length > 0) {
325
+ process.stderr.write(yellow(`${errors.length} divergência(s) na auditoria.\n`));
326
+ }
327
+ }
@@ -0,0 +1,359 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { apiFetch, ApiError } from '../api.js';
3
+ import { dim, fail, green, printJson, printTable, stringFlag } from './shared.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Limites e tipos — única fronteira de validação das referências de material
6
+ // (stage create e reference update usam OS MESMOS validadores).
7
+ // ---------------------------------------------------------------------------
8
+ /** Limites espelhando a validação do backend (V234/V243). */
9
+ export const MATERIAL_DRIVE_FILE_ID_MAX = 255;
10
+ export const MATERIAL_EXCERPT_MAX = 500;
11
+ export const MATERIAL_QUOTE_MAX = 1000;
12
+ export const MATERIAL_CONTEXT_MAX = 500;
13
+ const REGION_EPSILON = 1e-9;
14
+ /**
15
+ * Geometria da região: objeto {x,y,w,h} com números FINITOS, w/h > 0 e retângulo
16
+ * contido no unit square (0 <= x, 0 <= y, x+w <= 1, y+h <= 1). Um overshoot de até
17
+ * REGION_EPSILON é tolerado como ruído de float, mas NUNCA vai no fio: x/y são
18
+ * elevados a 0 e w/h encolhidos até a borda (1-x/1-y) — o resultado sempre satisfaz
19
+ * o CHECK estrito do backend. Overshoot além da tolerância (ou sem espaço p/ w/h > 0)
20
+ * continua erro. Usado pelo stage (materialReferences.region) e pelo reference update.
21
+ */
22
+ export function validateRegion(raw, where) {
23
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
24
+ fail(`${where}: region deve ser um objeto { x, y, w, h } (veio ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw}).`);
25
+ }
26
+ const r = raw;
27
+ for (const key of ['x', 'y', 'w', 'h']) {
28
+ const value = r[key];
29
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
30
+ fail(`${where}: region.${key} deve ser um número finito (veio ${JSON.stringify(value)}).`);
31
+ }
32
+ }
33
+ const region = r;
34
+ if (region.w <= 0 || region.h <= 0) {
35
+ fail(`${where}: region exige w e h > 0 (veio w=${region.w}, h=${region.h}).`);
36
+ }
37
+ if (region.x < -REGION_EPSILON ||
38
+ region.y < -REGION_EPSILON ||
39
+ region.x + region.w > 1 + REGION_EPSILON ||
40
+ region.y + region.h > 1 + REGION_EPSILON) {
41
+ fail(`${where}: region deve estar contida no unit square (0..1) — veio ` +
42
+ `{ x: ${region.x}, y: ${region.y}, w: ${region.w}, h: ${region.h} }.`);
43
+ }
44
+ // Clamp do overshoot tolerado: o wire nunca carrega valor fora do quadrado nem
45
+ // coordenada negativa (o CHECK do backend é estrito). x > 1 dentro da tolerância
46
+ // deixaria w/h sem espaço — degenera a região, então é rejeitado em vez de inventar
47
+ // coordenada.
48
+ const x = Math.max(0, region.x);
49
+ const y = Math.max(0, region.y);
50
+ const w = Math.min(region.w, 1 - x);
51
+ const h = Math.min(region.h, 1 - y);
52
+ if (w <= 0 || h <= 0) {
53
+ fail(`${where}: region deve estar contida no unit square (0..1) — veio ` +
54
+ `{ x: ${region.x}, y: ${region.y}, w: ${region.w}, h: ${region.h} }.`);
55
+ }
56
+ return { x, y, w, h };
57
+ }
58
+ /** Texto opcional do stage: string trim não-vazia até `max`; vazio normaliza para ausente. */
59
+ function validateStageText(raw, field, max, where) {
60
+ if (typeof raw !== 'string') {
61
+ fail(`${where}: ${field} deve ser uma string (veio ${typeof raw}).`);
62
+ }
63
+ const trimmed = raw.trim();
64
+ if (trimmed.length > max) {
65
+ fail(`${where}: ${field} tem ${trimmed.length} chars (máximo ${max}).`);
66
+ }
67
+ return trimmed === '' ? undefined : trimmed;
68
+ }
69
+ /**
70
+ * Valida e normaliza as referências de material do lote ANTES de qualquer chamada de API:
71
+ * cada entrada precisa ser objeto com driveFileId (string trim não vazia, até 255),
72
+ * page (inteiro >= 1, 1-based), excerpt/quote/contextBefore/contextAfter opcionais
73
+ * (trim nos tetos; vazio vira ausente) e region opcional (geometria validada por
74
+ * validateRegion). `undefined`/`null`/array vazio normalizam para ausente (o campo é
75
+ * omitido no POST, mantendo o payload antigo dos lotes sem referências).
76
+ */
77
+ export function validateMaterialReferences(raw, where) {
78
+ if (raw === undefined || raw === null) {
79
+ return undefined;
80
+ }
81
+ if (!Array.isArray(raw)) {
82
+ fail(`${where}: materialReferences deve ser um array (veio ${typeof raw}).`);
83
+ }
84
+ if (raw.length === 0) {
85
+ return undefined;
86
+ }
87
+ return raw.map((entry, index) => {
88
+ const ref = `${where}: materialReferences[${index + 1}]`;
89
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
90
+ fail(`${ref} deve ser um objeto { driveFileId, page, excerpt?, quote?, contextBefore?, contextAfter?, region? }.`);
91
+ }
92
+ const r = entry;
93
+ if (typeof r.driveFileId !== 'string' || r.driveFileId.trim() === '') {
94
+ fail(`${ref}: driveFileId é obrigatório (string não vazia — veja \`memoring scratch list --json\`).`);
95
+ }
96
+ const driveFileId = r.driveFileId.trim();
97
+ if (driveFileId.length > MATERIAL_DRIVE_FILE_ID_MAX) {
98
+ fail(`${ref}: driveFileId tem ${driveFileId.length} chars (máximo ${MATERIAL_DRIVE_FILE_ID_MAX}).`);
99
+ }
100
+ if (typeof r.page !== 'number' || !Number.isInteger(r.page) || r.page < 1) {
101
+ fail(`${ref}: page deve ser um inteiro >= 1 (página visível, 1-based — veio ${JSON.stringify(r.page)}).`);
102
+ }
103
+ let excerpt;
104
+ if (r.excerpt !== undefined && r.excerpt !== null) {
105
+ excerpt = validateStageText(r.excerpt, 'excerpt', MATERIAL_EXCERPT_MAX, ref);
106
+ }
107
+ let quote;
108
+ if (r.quote !== undefined && r.quote !== null) {
109
+ quote = validateStageText(r.quote, 'quote', MATERIAL_QUOTE_MAX, ref);
110
+ }
111
+ let contextBefore;
112
+ if (r.contextBefore !== undefined && r.contextBefore !== null) {
113
+ contextBefore = validateStageText(r.contextBefore, 'contextBefore', MATERIAL_CONTEXT_MAX, ref);
114
+ }
115
+ let contextAfter;
116
+ if (r.contextAfter !== undefined && r.contextAfter !== null) {
117
+ contextAfter = validateStageText(r.contextAfter, 'contextAfter', MATERIAL_CONTEXT_MAX, ref);
118
+ }
119
+ let region;
120
+ if (r.region !== undefined && r.region !== null) {
121
+ region = validateRegion(r.region, ref);
122
+ }
123
+ return {
124
+ driveFileId,
125
+ page: r.page,
126
+ ...(excerpt !== undefined ? { excerpt } : {}),
127
+ ...(quote !== undefined ? { quote } : {}),
128
+ ...(contextBefore !== undefined ? { contextBefore } : {}),
129
+ ...(contextAfter !== undefined ? { contextAfter } : {}),
130
+ ...(region !== undefined ? { region } : {}),
131
+ };
132
+ });
133
+ }
134
+ /**
135
+ * Conversão para o fio (POST /notes/review-inbox): page 1-based vira `pageIndex` 0-based
136
+ * (page NUNCA vaza) e os demais campos são repassados. Campos ausentes são omitidos —
137
+ * lotes antigos (só driveFileId/page/excerpt) saem byte a byte como antes.
138
+ */
139
+ export function toWireMaterialReference(ref) {
140
+ return {
141
+ driveFileId: ref.driveFileId,
142
+ pageIndex: ref.page - 1,
143
+ ...(ref.excerpt !== undefined ? { excerpt: ref.excerpt } : {}),
144
+ ...(ref.quote !== undefined ? { quote: ref.quote } : {}),
145
+ ...(ref.contextBefore !== undefined ? { contextBefore: ref.contextBefore } : {}),
146
+ ...(ref.contextAfter !== undefined ? { contextAfter: ref.contextAfter } : {}),
147
+ ...(ref.region !== undefined ? { region: ref.region } : {}),
148
+ };
149
+ }
150
+ /**
151
+ * Contrato das referências de material no idioma do AGENTE (page 1-based), como o
152
+ * `stage schema` o expõe. No POST cada referência sai como
153
+ * { driveFileId, pageIndex: page - 1, excerpt?, quote?, contextBefore?, contextAfter?, region? }.
154
+ */
155
+ export const MATERIAL_REFERENCE_CONTRACT = {
156
+ driveFileId: `string (obrigatório) — id do arquivo no Drive; descubra com \`memoring scratch list --json\` (trim, não vazio, até ${MATERIAL_DRIVE_FILE_ID_MAX} chars)`,
157
+ page: 'int 1-based (obrigatório) — página VISÍVEL do PDF (a posição 1-based no documento, como no `scratch extract --pages`); o POST converte para pageIndex 0-based',
158
+ excerpt: `string? — dica curta do trecho que originou o item (trim, até ${MATERIAL_EXCERPT_MAX} chars; vazio = omitido). É SÓ um rótulo humano: NÃO gera destaque no PDF`,
159
+ quote: `string? — trecho LITERAL do material que originou o item (trim, até ${MATERIAL_QUOTE_MAX} chars; vazio = omitido). RECOMENDADO para PDF com texto: é o que o app localiza e destaca na página`,
160
+ contextBefore: `string? — texto imediatamente ANTES do quote no material (trim, até ${MATERIAL_CONTEXT_MAX} chars; vazio = omitido) — desempata ocorrências repetidas do quote`,
161
+ contextAfter: `string? — texto imediatamente DEPOIS do quote no material (trim, até ${MATERIAL_CONTEXT_MAX} chars; vazio = omitido) — desempata ocorrências repetidas do quote`,
162
+ region: 'objeto? { x, y, w, h } — região visual da página, coordenadas normalizadas 0..1 com origem no topo-esquerdo (x,y) e tamanho (w,h); todos números FINITOS, w/h > 0 e retângulo contido no quadrado 0..1 (x+w <= 1, y+h <= 1). Para PDF ESCANEADO/sem texto: selecione a região do recorte em vez de quote. Alternativa ao quote — um ou outro (ou nenhum, só a página)',
163
+ };
164
+ // ---------------------------------------------------------------------------
165
+ // memoring reference list/update — manutenção das referências de um card.
166
+ // ---------------------------------------------------------------------------
167
+ const UPDATE_FIELDS = ['excerpt', 'quote', 'contextBefore', 'contextAfter', 'region'];
168
+ function parseId(arg, what, usage) {
169
+ const id = Number(arg);
170
+ if (!arg || !Number.isInteger(id) || id <= 0) {
171
+ fail(`informe o ${what} numérico: ${usage} (veja os ids com \`memoring reference list <noteId> --json\`).`);
172
+ }
173
+ return id;
174
+ }
175
+ async function fetchReferences(noteId) {
176
+ let body;
177
+ try {
178
+ body = await apiFetch(`/notes/${noteId}/material-references`);
179
+ }
180
+ catch (e) {
181
+ if (e instanceof ApiError && e.status === 404) {
182
+ fail(`card #${noteId} não encontrado (ou de outro usuário) — confira o id com \`memoring inbox list\` ou \`memoring deck list\`.`);
183
+ }
184
+ throw e;
185
+ }
186
+ const references = body?.references;
187
+ if (!Array.isArray(references)) {
188
+ fail(`resposta inesperada do servidor ao listar as referências do card #${noteId} (falta o array "references").`);
189
+ }
190
+ return references;
191
+ }
192
+ function truncate(value, max) {
193
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
194
+ }
195
+ /** Página de exibição (1-based, o idioma do agente) — '—' quando o servidor não a tem. */
196
+ function pageLabel(ref) {
197
+ return typeof ref.pageIndex === 'number' ? ref.pageIndex + 1 : '—';
198
+ }
199
+ async function listReferences(parsed, noteId) {
200
+ const references = await fetchReferences(noteId);
201
+ if (parsed.flags.json) {
202
+ printJson(references);
203
+ return;
204
+ }
205
+ if (references.length === 0) {
206
+ process.stdout.write(`Card #${noteId} não tem referências de material.\n`);
207
+ return;
208
+ }
209
+ printTable(references.map((r) => ({
210
+ id: `#${r.id}`,
211
+ page: pageLabel(r),
212
+ file: truncate(r.driveFileId ?? '', 28),
213
+ highlight: r.region ? 'região' : r.quote ? 'quote' : '—',
214
+ summary: truncate(r.excerpt ?? r.quote ?? '—', 48),
215
+ })), [
216
+ { key: 'id', label: 'ID' },
217
+ { key: 'page', label: 'PÁGINA' },
218
+ { key: 'file', label: 'ARQUIVO' },
219
+ { key: 'highlight', label: 'DESTAQUE' },
220
+ { key: 'summary', label: 'RESUMO' },
221
+ ]);
222
+ process.stdout.write(`\n${references.length} referência(s) no card #${noteId} — página exibida 1-based. ` +
223
+ 'Corrija excerpt/quote/contextos/região com `memoring reference update ' +
224
+ `<noteId> <referenceId> --file parcial.json\` (region: null limpa a região).\n`);
225
+ }
226
+ /**
227
+ * Merge de um campo de texto do patch com a referência atual: chave AUSENTE mantém o
228
+ * valor atual; null (ou string que trima para vazio) LIMPA; string válida valida
229
+ * (trim + teto) e substitui.
230
+ */
231
+ function mergeTextField(current, raw, field, max, where) {
232
+ if (raw === undefined) {
233
+ return current ?? null;
234
+ }
235
+ if (raw === null) {
236
+ return null;
237
+ }
238
+ if (typeof raw !== 'string') {
239
+ fail(`${where}: ${field} deve ser uma string ou null (veio ${typeof raw}).`);
240
+ }
241
+ const trimmed = raw.trim();
242
+ if (trimmed.length > max) {
243
+ fail(`${where}: ${field} tem ${trimmed.length} chars (máximo ${max}).`);
244
+ }
245
+ return trimmed === '' ? null : trimmed;
246
+ }
247
+ /** Merge da região: chave AUSENTE mantém; null LIMPA; objeto valida pela geometria comum. */
248
+ function mergeRegion(current, raw, where) {
249
+ if (raw === undefined) {
250
+ return current ?? null;
251
+ }
252
+ if (raw === null) {
253
+ return null;
254
+ }
255
+ return validateRegion(raw, where);
256
+ }
257
+ function readPatchFile(parsed) {
258
+ const file = stringFlag(parsed.flags, 'file');
259
+ if (!file) {
260
+ fail('informe o patch: memoring reference update <noteId> <referenceId> --file parcial.json ' +
261
+ '(excerpt/quote/contextBefore/contextAfter/region — campos ausentes são mantidos; region: null limpa).');
262
+ }
263
+ let patch;
264
+ try {
265
+ patch = JSON.parse(readFileSync(file, 'utf8'));
266
+ }
267
+ catch (e) {
268
+ fail(`JSON inválido em --file: ${e.message}`);
269
+ }
270
+ if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
271
+ fail(`--file deve ser um objeto JSON parcial com os campos ${UPDATE_FIELDS.map((f) => `"${f}"`).join(', ')} (veio ${Array.isArray(patch) ? 'array' : typeof patch}).`);
272
+ }
273
+ const record = patch;
274
+ const unknown = Object.keys(record).filter((k) => !UPDATE_FIELDS.includes(k));
275
+ if (unknown.length > 0) {
276
+ fail(`campo(s) desconhecido(s) no patch: ${unknown.map((k) => `"${k}"`).join(', ')} — o update só aceita ` +
277
+ `${UPDATE_FIELDS.map((f) => `"${f}"`).join(', ')} (nota, driveFileId e página NÃO são alterados por aqui).`);
278
+ }
279
+ return record;
280
+ }
281
+ async function updateReference(parsed, noteId, referenceId) {
282
+ const patch = readPatchFile(parsed);
283
+ const references = await fetchReferences(noteId);
284
+ const current = references.find((r) => r.id === referenceId);
285
+ if (!current) {
286
+ fail(`referência #${referenceId} não encontrada no card #${noteId} — veja os ids com \`memoring reference list ${noteId} --json\`.`);
287
+ }
288
+ const where = `referência #${referenceId}`;
289
+ // PUT full replace DESSAS METADADAS: o body sempre leva os 5 campos com o resultado
290
+ // do merge (ausente = manter o atual; null = limpar). Nota, driveFileId e página
291
+ // não entram no body — o servidor não os troca por esse endpoint.
292
+ const body = {
293
+ excerpt: mergeTextField(current.excerpt, patch.excerpt, 'excerpt', MATERIAL_EXCERPT_MAX, where),
294
+ quote: mergeTextField(current.quote, patch.quote, 'quote', MATERIAL_QUOTE_MAX, where),
295
+ contextBefore: mergeTextField(current.contextBefore, patch.contextBefore, 'contextBefore', MATERIAL_CONTEXT_MAX, where),
296
+ contextAfter: mergeTextField(current.contextAfter, patch.contextAfter, 'contextAfter', MATERIAL_CONTEXT_MAX, where),
297
+ region: mergeRegion(current.region, patch.region, where),
298
+ };
299
+ let updated;
300
+ try {
301
+ updated = (await apiFetch(`/notes/${noteId}/material-references/${referenceId}`, {
302
+ method: 'PUT',
303
+ body,
304
+ }));
305
+ }
306
+ catch (e) {
307
+ if (e instanceof ApiError) {
308
+ if (e.status === 404) {
309
+ fail(`referência #${referenceId} não encontrada no card #${noteId} (ou de outro usuário) — confira com \`memoring reference list ${noteId} --json\`.`);
310
+ }
311
+ fail(`o servidor rejeitou a atualização (${e.status}): ${e.body}`);
312
+ }
313
+ throw e;
314
+ }
315
+ const view = updated ?? { ...current, ...body };
316
+ if (parsed.flags.json) {
317
+ printJson(view);
318
+ return;
319
+ }
320
+ const showText = (v) => (v ? `"${truncate(v, 60)}"` : '—');
321
+ process.stdout.write(green(`Referência #${referenceId} do card #${noteId} atualizada:\n`));
322
+ process.stdout.write(` página ${pageLabel(view)} (inalterada — não se troca por aqui)\n`);
323
+ process.stdout.write(` arquivo ${view.driveFileId ?? '—'} (inalterado)\n`);
324
+ process.stdout.write(` excerpt ${showText(view.excerpt)}\n`);
325
+ process.stdout.write(` quote ${showText(view.quote)}\n`);
326
+ process.stdout.write(` contextBefore ${showText(view.contextBefore)}\n`);
327
+ process.stdout.write(` contextAfter ${showText(view.contextAfter)}\n`);
328
+ process.stdout.write(` region ${view.region ? `{ x: ${view.region.x}, y: ${view.region.y}, w: ${view.region.w}, h: ${view.region.h} }` : '—'}\n`);
329
+ process.stdout.write(dim('Sem quote nem região o app abre a página sem destaque — excerpt é só uma dica curta.\n'));
330
+ }
331
+ export async function referenceCommand(parsed) {
332
+ const [sub, noteIdArg, referenceIdArg] = parsed._;
333
+ switch (sub) {
334
+ case 'list': {
335
+ const noteId = parseId(noteIdArg, 'id do card', 'memoring reference list <noteId>');
336
+ await listReferences(parsed, noteId);
337
+ return;
338
+ }
339
+ case 'update': {
340
+ const noteId = parseId(noteIdArg, 'id do card', 'memoring reference update <noteId> <referenceId> --file parcial.json');
341
+ const referenceId = parseId(referenceIdArg, 'id da referência', 'memoring reference update <noteId> <referenceId> --file parcial.json');
342
+ await updateReference(parsed, noteId, referenceId);
343
+ return;
344
+ }
345
+ default:
346
+ process.stdout.write('Uso: memoring reference <list|update> [--json]\n' +
347
+ ' list <noteId> referências de material de um card (fontes de estudo)\n' +
348
+ ' — GET /notes/{noteId}/material-references; a página sai\n' +
349
+ ' 1-based e o DESTAQUE mostra se há quote ou região\n' +
350
+ ' update <noteId> <referenceId> corrige excerpt/quote/contextBefore/contextAfter/region\n' +
351
+ ' --file parcial.json (merge: campo ausente mantém o atual; null ou string\n' +
352
+ ' vazia LIMPA; region valida x,y,w,h em 0..1, w/h > 0).\n' +
353
+ ' Não altera nota, arquivo nem página. --json devolve a\n' +
354
+ ' referência atualizada\n');
355
+ if (sub) {
356
+ process.exitCode = 1;
357
+ }
358
+ }
359
+ }