orquestra-mcp 1.55.0 → 1.58.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/bin/orquestra-mcp.mjs +4 -2
- package/package.json +1 -1
- package/src/server.mjs +8 -4
- package/src/vaultAttachments.mjs +190 -0
- package/src/vaultSync.mjs +275 -24
package/bin/orquestra-mcp.mjs
CHANGED
|
@@ -15,8 +15,10 @@ Uso:
|
|
|
15
15
|
Lo mismo para una herramienta puntual (claude-code, codex,
|
|
16
16
|
cursor, gemini-cli...): cada una firma con su propio agente
|
|
17
17
|
npx orquestra-mcp vault sync <carpeta> [--apply]
|
|
18
|
-
Sincroniza la base de conocimiento del workspace
|
|
19
|
-
carpeta de tu vault de Obsidian
|
|
18
|
+
Sincroniza la base de conocimiento del workspace (notas e
|
|
19
|
+
imágenes/videos) con una carpeta de tu vault de Obsidian.
|
|
20
|
+
La 1ra vez solo muestra el plan; --help lista las opciones
|
|
21
|
+
(--dry-run, --no-attachments, --verbose)
|
|
20
22
|
npx orquestra-mcp --help Muestra esta ayuda
|
|
21
23
|
|
|
22
24
|
Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.58.0",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 135 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server.mjs
CHANGED
|
@@ -284,7 +284,7 @@ export function createServer() {
|
|
|
284
284
|
registerRead(
|
|
285
285
|
'list_tasks',
|
|
286
286
|
'/v1/tasks',
|
|
287
|
-
'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true. No incluye actividades (reuniones/llamadas/seguimientos) -- esas van con list_activities.',
|
|
287
|
+
'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag/groupKey (código de agrupación, ej. V2). Cada tarea trae su groupCode si lo tiene. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true. No incluye actividades (reuniones/llamadas/seguimientos) -- esas van con list_activities.',
|
|
288
288
|
{
|
|
289
289
|
statusId: z.string().optional().describe('ID de estatus para filtrar (opcional) -- pedir un estatus puntual (incluso uno cerrado) siempre se respeta tal cual, sin el filtro default de abiertas'),
|
|
290
290
|
moduleId: z.string().optional().describe('ID de módulo para filtrar (opcional)'),
|
|
@@ -293,6 +293,7 @@ export function createServer() {
|
|
|
293
293
|
assignedTo: z.string().optional().describe('uid del responsable para filtrar (opcional)'),
|
|
294
294
|
tag: z.string().optional().describe('Un tag exacto para filtrar -- solo uno, no una lista (opcional)'),
|
|
295
295
|
workCategory: z.enum(WORK_CATEGORIES).optional().describe('Categoría de trabajo para filtrar (opcional): plan | build | delivery | review'),
|
|
296
|
+
groupKey: z.string().max(10).optional().describe('Clave de agrupación exacta (ej. "V2") para traer solo las tareas de ese grupo (groupCode V2-1, V2-2...) -- úsalo antes de crear una tarea nueva de una iniciativa para reutilizar la clave y ver cuáles existen (opcional)'),
|
|
296
297
|
includeClosed: z.boolean().optional().describe('Default false -- sin esto, tareas en un estatus isFinal/isCancel/isArchive quedan afuera. Ponelo en true solo si de verdad necesitás el historial cerrado (auditoría, "¿cuándo se hizo X?").'),
|
|
297
298
|
},
|
|
298
299
|
(args, projectId) => ({
|
|
@@ -304,6 +305,7 @@ export function createServer() {
|
|
|
304
305
|
assignedTo: args.assignedTo,
|
|
305
306
|
tag: args.tag,
|
|
306
307
|
workCategory: args.workCategory,
|
|
308
|
+
groupKey: args.groupKey,
|
|
307
309
|
includeClosed: args.includeClosed,
|
|
308
310
|
})
|
|
309
311
|
)
|
|
@@ -788,6 +790,7 @@ export function createServer() {
|
|
|
788
790
|
skipParity: z.boolean().optional().describe('true = NO sumar las plataformas hermanas al scope aunque tengan el mismo productKey. Solo cuando el trabajo es genuinamente de UNA plataforma (ej. un ajuste de Gradle que no tiene contraparte en iOS) -- no lo mandes por default. Las tareas type="bug" nunca propagan, sin necesidad de esto.'),
|
|
789
791
|
parityExempt: z.boolean().optional().describe('true = este trabajo es de UNA sola plataforma y no cuenta en get_parity_summary (se pone solo cuando skipParity va con scope de una sola dist). false para volver a contarla.'),
|
|
790
792
|
parityOf: z.string().optional().describe('Folio: id de la tarea ORIGEN de la que esta es la hermana para otra plataforma (ej. la versión iOS de una tarea de Android). En paridad cuentan como UN item y al cerrar esta, su plataforma queda hecha en el origen. El origen no puede ser hermana de otra. "" lo quita.'),
|
|
793
|
+
groupKey: z.string().max(10).optional().describe('Código de agrupación (ORQ-464): clave corta de una iniciativa que cruza módulos (ej. "V2", "LANZ"; 2-10 letras/números, empieza con letra). El server asigna groupCode = clave + número ("V2-3"), separado del moduleCode y del folio ORQ-N. Úsalo cuando la tarea es parte de una iniciativa/entrega mayor que atraviesa varios módulos; propónlo tú al armar ese trabajo. Reutiliza la MISMA clave para todas las tareas del mismo grupo y revisa con list_tasks (groupKey) las que ya existen antes de inventar una nueva. No uses la clave de un módulo (400). Sin groupKey la tarea no lleva grupo.'),
|
|
791
794
|
moduleCode: z.string().max(40).optional().describe('Clave de la tarea dentro de su módulo (ej. "AUTH-3"), ADEMÁS del folio ORQ-N. NO la mandes normalmente: si el módulo tiene clave (key), el server la asigna sola. Solo para conservar la clave que ya traía de otro sistema al importar (ej. "PROJ-128" de Jira/Linear).'),
|
|
792
795
|
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea. LEGACY desde ORQ-248: si vas a bloquear por más de una tarea, usa blockedByIds en vez de esto (relatedTaskId se sigue llenando solo, como blockedByIds[0], para paridad con iOS/Android).'),
|
|
793
796
|
blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- ids de otras tareas (puede ser más de una). Esta tarea se muestra como bloqueada mientras ALGUNA de esas no llegue a un estatus final (isFinal). Reemplaza a relatedTaskId para bloqueos múltiples -- preferí este campo, no relatedTaskId, salvo que de verdad sea un solo bloqueador.'),
|
|
@@ -836,6 +839,7 @@ export function createServer() {
|
|
|
836
839
|
.describe('Alcance por distribución -- usa get_parity_summary para ver el scope actual antes de editarlo'),
|
|
837
840
|
parityExempt: z.boolean().optional().describe('true = este trabajo es de UNA sola plataforma y no cuenta en get_parity_summary; false para volver a contarla.'),
|
|
838
841
|
parityOf: z.string().optional().describe('Folio: id de la tarea ORIGEN de la que esta es la hermana para otra plataforma (ej. la versión iOS de una tarea de Android). En paridad cuentan como UN item y al cerrar esta, su plataforma queda hecha en el origen. El origen no puede ser hermana de otra. "" lo quita.'),
|
|
842
|
+
groupKey: z.string().max(10).optional().describe('Código de agrupación (ORQ-464): clave corta de una iniciativa que cruza módulos (ej. "V2"; 2-10 letras/números, empieza con letra). Estable: editar la tarea sin groupKey NO cambia su groupCode ni se gasta número si mandas la misma clave. Otra clave la reasigna (número nuevo de esa clave, ej. "LANZ-1"); "" quita el código. Reutiliza la misma clave para las tareas del mismo grupo y revisa antes con list_tasks (groupKey). No uses la clave de un módulo (400).'),
|
|
839
843
|
moduleCode: z.string().max(40).optional().describe('Clave de la tarea dentro de su módulo (ej. "AUTH-3"), texto libre editable a mano. "" la quita. Mover la tarea a otro módulo (moduleId) NO la cambia: la clave es estable.'),
|
|
840
844
|
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea, o "" para quitar el bloqueo. LEGACY, ver create_task -- preferí blockedByIds.'),
|
|
841
845
|
blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- reemplaza el array COMPLETO (no hace merge), [] quita todos los bloqueos. Ver create_task.'),
|
|
@@ -872,8 +876,8 @@ export function createServer() {
|
|
|
872
876
|
location: z.string().optional().describe('Plataforma o lugar ("Zoom", "Oficina del cliente") o contexto libre para un seguimiento.'),
|
|
873
877
|
participants: z.array(z.string()).optional().describe('Nombres libres de quienes participan (cliente, equipo) -- no uids.'),
|
|
874
878
|
description: z.string().optional().describe('Notas previas.'),
|
|
875
|
-
agendaItems: z.array(z.union([z.string(), z.object({ id: z.string().optional(), text: z.string(), group: z.string().max(60).optional(), createdAt: z.number().optional(), createdBy: z.string().optional() })])).optional()
|
|
876
|
-
.describe('Puntos a revisar en la reunión (ej. "Revisar [[ORQ-123]]"). Lista simple, NO checklist. Cada punto puede llevar group (agrupa puntos bajo un encabezado, máx. 60 caracteres). Reemplaza la lista COMPLETA: para agregar uno, lee la actual con list_activities y manda todos, con sus id para conservarlos Y con su group -- si reenvías un punto sin group, pierde su grupo.'),
|
|
879
|
+
agendaItems: z.array(z.union([z.string(), z.object({ id: z.string().optional(), text: z.string(), group: z.string().max(60).optional(), resolution: z.string().max(2000).optional(), resolutionStatus: z.enum(['pendiente', 'decidido', 'pospuesto', '']).optional(), createdAt: z.number().optional(), createdBy: z.string().optional() })])).optional()
|
|
880
|
+
.describe('Puntos a revisar en la reunión (ej. "Revisar [[ORQ-123]]"). Lista simple, NO checklist. Cada punto puede llevar group (agrupa puntos bajo un encabezado, máx. 60 caracteres). Reemplaza la lista COMPLETA: para agregar uno, lee la actual con list_activities y manda todos, con sus id para conservarlos Y con su group -- si reenvías un punto sin group, pierde su grupo. Cada punto puede llevar además resolution (qué se decidió, máx. 2000 caracteres) y resolutionStatus ("pendiente" | "decidido" | "pospuesto"): son informativos, NO bloquean el cierre de la actividad. Si reenvías un punto con su id sin esos campos, el server conserva los guardados; manda "" para borrarlos. list_activities los devuelve en cada punto.'),
|
|
877
881
|
outcome: z.string().optional().describe('Resultado / minuta corta -- qué se acordó. Llénalo al marcarla hecha.'),
|
|
878
882
|
nextStep: z.string().optional().describe('Siguiente paso acordado. Si es trabajo real, además crea la tarea con create_task y liga su id en nextStepTaskId.'),
|
|
879
883
|
nextStepTaskId: z.string().optional().describe('id de la tarea creada desde nextStep.'),
|
|
@@ -1041,7 +1045,7 @@ export function createServer() {
|
|
|
1041
1045
|
registerRead(
|
|
1042
1046
|
'list_activities',
|
|
1043
1047
|
'/v1/activities',
|
|
1044
|
-
'Lista las actividades (reuniones, llamadas, seguimientos) de un proyecto de Orquestra. status "scheduled" = agenda (próximas primero), "done" = bitácora (más recientes primero); sin status trae todas. from/to acotan por día.',
|
|
1048
|
+
'Lista las actividades (reuniones, llamadas, seguimientos) de un proyecto de Orquestra. status "scheduled" = agenda (próximas primero), "done" = bitácora (más recientes primero); sin status trae todas. from/to acotan por día. Cada punto de agendaItems puede traer resolution y resolutionStatus (pendiente/decidido/pospuesto).',
|
|
1045
1049
|
{
|
|
1046
1050
|
status: z.enum(ACTIVITY_STATUSES).optional(),
|
|
1047
1051
|
type: z.enum(ACTIVITY_TYPES).optional(),
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Imágenes y videos del vault de Obsidian (ORQ-299): se suben a S3 como adjuntos de la
|
|
2
|
+
// entrada de conocimiento (`entityType: 'knowledge'`, /v1/attachments/upload-url +
|
|
3
|
+
// POST /v1/knowledge/{id}/attachments) y se bajan de vuelta al vault en otra máquina.
|
|
4
|
+
//
|
|
5
|
+
// El texto de la nota NO se reescribe: `![[foto.png]]` / `` quedan tal cual,
|
|
6
|
+
// porque el adjunto guarda como `fileName` la ruta del archivo relativa al vault. Así un
|
|
7
|
+
// `vault sync` en otra Mac vuelve a poner cada archivo donde Obsidian lo espera.
|
|
8
|
+
//
|
|
9
|
+
// Reglas (iguales a las del adjunto normal, `add_attachment`): png/jpg/jpeg/gif/webp hasta
|
|
10
|
+
// 15 MB y mp4/mov/webm hasta 500 MB. Cualquier otro tipo (pdf, audio, zip...) se reporta y no
|
|
11
|
+
// se sincroniza. Nunca se borra un archivo del vault; solo se reemplaza en S3 la versión
|
|
12
|
+
// anterior de un archivo que cambió.
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { promises as fs } from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { apiGet, apiPost, apiDelete, putToPresignedUrl } from './api.mjs'
|
|
17
|
+
|
|
18
|
+
export const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
19
|
+
export const VIDEO_MIME = { mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm' }
|
|
20
|
+
export const MAX_IMAGE_BYTES = 15 * 1024 * 1024
|
|
21
|
+
export const MAX_VIDEO_BYTES = 500 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
const toPosix = p => p.split(path.sep).join('/')
|
|
24
|
+
const extOf = p => path.extname(p).slice(1).toLowerCase()
|
|
25
|
+
|
|
26
|
+
// ── Referencias (puro) ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Referencias a archivos incrustados en el texto de una nota: `![[x|alias]]` y `` (no URLs). */
|
|
29
|
+
export function embedRefs(body) {
|
|
30
|
+
const refs = []
|
|
31
|
+
for (const m of (body || '').matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:|data:)([^)]+)\)/g)) {
|
|
32
|
+
let ref = m[1]
|
|
33
|
+
if (ref !== undefined) {
|
|
34
|
+
ref = ref.split('|')[0].split('#')[0].trim()
|
|
35
|
+
} else {
|
|
36
|
+
ref = m[2].trim().replace(/^<|>$/g, '').replace(/\s+"[^"]*"$/, '')
|
|
37
|
+
try { ref = decodeURIComponent(ref) } catch { /* se queda como venía */ }
|
|
38
|
+
ref = ref.split('#')[0]
|
|
39
|
+
}
|
|
40
|
+
if (ref) refs.push(ref)
|
|
41
|
+
}
|
|
42
|
+
return [...new Set(refs)]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Índice de archivos del vault para resolver referencias: { all: Set<rel>, byBase: Map<lowerBasename, rel[]> }. */
|
|
46
|
+
export function buildFileIndex(relPaths) {
|
|
47
|
+
const all = new Set(relPaths)
|
|
48
|
+
const byBase = new Map()
|
|
49
|
+
for (const rel of relPaths) {
|
|
50
|
+
const key = path.posix.basename(rel).toLowerCase()
|
|
51
|
+
byBase.set(key, [...(byBase.get(key) || []), rel].sort())
|
|
52
|
+
}
|
|
53
|
+
return { all, byBase }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ruta (relativa al vault) del archivo al que apunta `ref`, o null si no existe.
|
|
58
|
+
* Mismo orden que Obsidian: ruta desde la raíz del vault, ruta relativa a la nota y, por último,
|
|
59
|
+
* solo el nombre (el más cercano a la nota si hay varios con el mismo nombre).
|
|
60
|
+
*/
|
|
61
|
+
export function resolveRef(ref, noteFile, index) {
|
|
62
|
+
const clean = ref.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
63
|
+
if (index.all.has(clean)) return clean
|
|
64
|
+
const near = path.posix.normalize(path.posix.join(path.posix.dirname(toPosix(noteFile)), clean))
|
|
65
|
+
if (!near.startsWith('..') && index.all.has(near)) return near
|
|
66
|
+
const candidates = index.byBase.get(path.posix.basename(clean).toLowerCase())
|
|
67
|
+
if (!candidates?.length) return null
|
|
68
|
+
const dir = path.posix.dirname(toPosix(noteFile))
|
|
69
|
+
return candidates.find(c => path.posix.dirname(c) === dir) || candidates[0]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ¿Se sincroniza este archivo? → { ok, kind, mime } o { ok:false, reason: 'unsupported' | 'toolarge' }. */
|
|
73
|
+
export function classifyFile(rel, size) {
|
|
74
|
+
const ext = extOf(rel)
|
|
75
|
+
const image = IMAGE_MIME[ext]
|
|
76
|
+
const video = VIDEO_MIME[ext]
|
|
77
|
+
if (!image && !video) return { ok: false, reason: 'unsupported' }
|
|
78
|
+
const max = image ? MAX_IMAGE_BYTES : MAX_VIDEO_BYTES
|
|
79
|
+
if (size > max) return { ok: false, reason: 'toolarge', maxMb: max / 1024 / 1024 }
|
|
80
|
+
return { ok: true, kind: image ? 'image' : 'video', mime: image || video }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Seguro para escribir dentro del vault: relativa, sin `..` ni rutas absolutas. */
|
|
84
|
+
export function isSafeRelPath(rel) {
|
|
85
|
+
if (!rel || path.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) return false
|
|
86
|
+
const norm = path.posix.normalize(String(rel).replace(/\\/g, '/'))
|
|
87
|
+
return !norm.startsWith('..') && !norm.startsWith('/') && !norm.split('/').includes('.git')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Plan de subida (puro) ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* notes: [{file, id|null, title, body}]
|
|
94
|
+
* files: Map<rel, {size, hash}> (solo hace falta de los archivos referenciados; ver `fileInfoFor`)
|
|
95
|
+
* index: salida de buildFileIndex
|
|
96
|
+
* attState: { [entryId]: { [rel]: {attachmentId, hash} } }
|
|
97
|
+
* → [{ note, rel, status, ... }] con status:
|
|
98
|
+
* upload (nuevo) | replace (cambió) | unchanged | missing | unsupported | toolarge
|
|
99
|
+
* Una misma imagen usada en dos notas se sube una vez por nota (cada entrada tiene sus adjuntos).
|
|
100
|
+
*/
|
|
101
|
+
export function planAttachmentUploads({ notes, files, index, attState = {} }) {
|
|
102
|
+
const out = []
|
|
103
|
+
for (const note of notes) {
|
|
104
|
+
for (const ref of embedRefs(note.body)) {
|
|
105
|
+
const ext = extOf(ref)
|
|
106
|
+
// `![[Otra nota]]` (transclusión) no tiene extensión de archivo: no es un adjunto.
|
|
107
|
+
if (!ext || ext === 'md') continue
|
|
108
|
+
const rel = resolveRef(ref, note.file, index)
|
|
109
|
+
if (!rel) { out.push({ note, ref, rel: null, status: 'missing' }); continue }
|
|
110
|
+
const info = files.get(rel)
|
|
111
|
+
const cls = classifyFile(rel, info?.size ?? 0)
|
|
112
|
+
if (!cls.ok) { out.push({ note, ref, rel, status: cls.reason, size: info?.size, maxMb: cls.maxMb }); continue }
|
|
113
|
+
const prev = note.id ? attState[note.id]?.[rel] : null
|
|
114
|
+
const status = !prev ? 'upload' : prev.hash === info.hash ? 'unchanged' : 'replace'
|
|
115
|
+
out.push({ note, ref, rel, status, size: info.size, hash: info.hash, kind: cls.kind, mime: cls.mime, prev })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Disco ────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** Todos los archivos NO .md del vault (rutas relativas, posix). Las carpetas/archivos que empiezan con "." se saltan (.obsidian). */
|
|
124
|
+
export async function scanVaultFiles(dir, rel = '') {
|
|
125
|
+
const out = []
|
|
126
|
+
for (const ent of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
127
|
+
if (ent.name.startsWith('.')) continue
|
|
128
|
+
const relPath = path.join(rel, ent.name)
|
|
129
|
+
if (ent.isDirectory()) out.push(...await scanVaultFiles(dir, relPath))
|
|
130
|
+
else if (!ent.name.endsWith('.md')) out.push(toPosix(relPath))
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Tamaño y hash de contenido de los archivos referenciados (solo los que existen). */
|
|
136
|
+
export async function fileInfoFor(dir, rels) {
|
|
137
|
+
const info = new Map()
|
|
138
|
+
for (const rel of new Set(rels)) {
|
|
139
|
+
try {
|
|
140
|
+
const buf = await fs.readFile(path.join(dir, rel))
|
|
141
|
+
info.set(rel, { size: buf.length, hash: createHash('sha1').update(buf).digest('hex') })
|
|
142
|
+
} catch { /* desaparecido entre el escaneo y la lectura: queda como missing */ }
|
|
143
|
+
}
|
|
144
|
+
return info
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Red ──────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Adjuntos de una entrada en Orquestra, uno por ruta (si hay repetidos gana el más reciente). */
|
|
150
|
+
export async function listRemoteAttachments(config, projectId, entryId) {
|
|
151
|
+
const { items } = await apiGet(config, `/v1/knowledge/${entryId}/attachments`, { projectId })
|
|
152
|
+
const byPath = new Map()
|
|
153
|
+
for (const a of items || []) {
|
|
154
|
+
if ((a.kind !== 'image' && a.kind !== 'video') || !a.key || !a.fileName) continue
|
|
155
|
+
const prev = byPath.get(a.fileName)
|
|
156
|
+
if (!prev || (a.createdAt || 0) > (prev.createdAt || 0)) byPath.set(a.fileName, a)
|
|
157
|
+
}
|
|
158
|
+
return byPath
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Sube un archivo del vault como adjunto de la entrada; si reemplaza uno anterior, borra la versión vieja. */
|
|
162
|
+
export async function uploadAttachment(config, projectId, entryId, dir, item) {
|
|
163
|
+
const buffer = await fs.readFile(path.join(dir, item.rel))
|
|
164
|
+
const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
|
|
165
|
+
projectId, entityType: 'knowledge', entityId: entryId,
|
|
166
|
+
fileName: item.rel, fileType: item.mime, fileSize: item.size,
|
|
167
|
+
})
|
|
168
|
+
await putToPresignedUrl(uploadUrl, buffer, item.mime)
|
|
169
|
+
const created = await apiPost(config, `/v1/knowledge/${entryId}/attachments`, {
|
|
170
|
+
projectId, kind: item.kind, key, fileName: item.rel, fileType: item.mime, fileSize: item.size,
|
|
171
|
+
})
|
|
172
|
+
if (item.prev?.attachmentId) {
|
|
173
|
+
// Es la versión anterior del MISMO archivo (el id sale de nuestro estado): la nueva ya quedó guardada.
|
|
174
|
+
await apiDelete(config, `/v1/knowledge/${entryId}/attachments`, { projectId, id: item.prev.attachmentId }).catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
return { attachmentId: created.id, hash: item.hash, size: item.size }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Baja un adjunto al vault SIN pisar un archivo que ya exista. Devuelve true si lo escribió. */
|
|
180
|
+
export async function downloadAttachment(config, projectId, dir, attachment) {
|
|
181
|
+
if (!isSafeRelPath(attachment.fileName)) throw new Error(`ruta no segura en Orquestra: ${attachment.fileName}`)
|
|
182
|
+
const target = path.join(dir, attachment.fileName)
|
|
183
|
+
if (await fs.access(target).then(() => true, () => false)) return false
|
|
184
|
+
const { url } = await apiGet(config, '/v1/attachments/view-url', { projectId, key: attachment.key })
|
|
185
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) })
|
|
186
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} al bajar ${attachment.fileName}`)
|
|
187
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
188
|
+
await fs.writeFile(target, Buffer.from(await res.arrayBuffer()))
|
|
189
|
+
return true
|
|
190
|
+
}
|
package/src/vaultSync.mjs
CHANGED
|
@@ -13,11 +13,16 @@
|
|
|
13
13
|
// como "<nombre>.conflict.md" junto al archivo. Nada se borra nunca: una
|
|
14
14
|
// entrada borrada en el vault se vuelve a bajar, y un .md con orquestraId que
|
|
15
15
|
// ya no existe en Orquestra solo se reporta.
|
|
16
|
-
//
|
|
16
|
+
// Imágenes y videos del vault (ORQ-299): se suben a S3 como adjuntos de la entrada y se bajan
|
|
17
|
+
// de vuelta en otra máquina -- ver src/vaultAttachments.mjs. `--no-attachments` los salta.
|
|
17
18
|
import { createHash } from 'node:crypto'
|
|
18
19
|
import { promises as fs } from 'node:fs'
|
|
19
20
|
import path from 'node:path'
|
|
20
21
|
import { loadConfig, apiGet, apiPost, apiPatch } from './api.mjs'
|
|
22
|
+
import {
|
|
23
|
+
buildFileIndex, planAttachmentUploads, scanVaultFiles, fileInfoFor, embedRefs, resolveRef,
|
|
24
|
+
listRemoteAttachments, uploadAttachment, downloadAttachment, isSafeRelPath,
|
|
25
|
+
} from './vaultAttachments.mjs'
|
|
21
26
|
|
|
22
27
|
export const STATE_FILE = '.orquestra-sync.json'
|
|
23
28
|
const CONFLICT_SUFFIX = '.conflict.md'
|
|
@@ -142,7 +147,6 @@ async function scanVault(dir, rel = '') {
|
|
|
142
147
|
async function readLocal(dir) {
|
|
143
148
|
const files = await scanVault(dir)
|
|
144
149
|
const notes = []
|
|
145
|
-
const attachments = []
|
|
146
150
|
for (const file of files) {
|
|
147
151
|
const full = path.join(dir, file)
|
|
148
152
|
const [text, stat] = await Promise.all([fs.readFile(full, 'utf8'), fs.stat(full)])
|
|
@@ -152,9 +156,8 @@ async function readLocal(dir) {
|
|
|
152
156
|
const note = { file, title, body, tags: meta.tags, id: meta.orquestraId || null, mtimeMs: stat.mtimeMs, extra }
|
|
153
157
|
note.hash = contentHash(note)
|
|
154
158
|
notes.push(note)
|
|
155
|
-
for (const emb of body.matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:)([^)]+)\)/g)) attachments.push({ file, ref: emb[1] || emb[2] })
|
|
156
159
|
}
|
|
157
|
-
return { notes
|
|
160
|
+
return { notes }
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
async function readState(dir) {
|
|
@@ -169,18 +172,154 @@ function uniqueFile(title, taken) {
|
|
|
169
172
|
return name
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
|
|
176
|
+
// ── Reporte (puro) ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const LABEL = {
|
|
179
|
+
pull: '↓ Bajar de Orquestra al vault',
|
|
180
|
+
push: '↑ Subir del vault a Orquestra',
|
|
181
|
+
create: '↑ Crear en Orquestra (notas nuevas del vault)',
|
|
182
|
+
conflict: '⚠ Conflictos (cambiaron en los dos lados)',
|
|
183
|
+
orphan: '? Sin entrada en Orquestra',
|
|
184
|
+
}
|
|
185
|
+
const OP_ORDER = ['pull', 'push', 'create', 'conflict', 'orphan']
|
|
186
|
+
const DONE_LABEL = { pull: '↓ bajada', push: '↑ subida', create: '↑ creada', conflict: '⚠ conflicto resuelto', orphan: '? sin entrada' }
|
|
187
|
+
|
|
188
|
+
export function formatBytes(n) {
|
|
189
|
+
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
190
|
+
if (n >= 1024) return `${Math.round(n / 1024)} KB`
|
|
191
|
+
return `${n} B`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function ago(ms, now = Date.now()) {
|
|
195
|
+
const min = Math.floor((now - ms) / 60000)
|
|
196
|
+
if (min < 1) return 'hace un momento'
|
|
197
|
+
if (min < 60) return `hace ${min} min`
|
|
198
|
+
const h = Math.round(min / 60)
|
|
199
|
+
if (h < 24) return `hace ${h} h`
|
|
200
|
+
return `hace ${Math.round(h / 24)} d`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const itemName = p => p.local?.file || p.remote?.title
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Texto del reporte antes de aplicar (o la vista previa). Todo lo que decide el usuario está aquí:
|
|
207
|
+
* qué pasa con cada nota y cada archivo, por qué, y el siguiente paso.
|
|
208
|
+
* att: { enabled, uploads:[{rel,title,size,status}], downloads:[{rel,title,size?}], unchanged:n,
|
|
209
|
+
* missing:[{rel,title}], unsupported:[{rel,title}], toolarge:[{rel,title,size,maxMb}] }
|
|
210
|
+
*/
|
|
211
|
+
export function renderReport({ dir, dirArg, apply, firstRun, lastSyncAt, remoteCount, localCount, plan, att, verbose = false, now = Date.now() }) {
|
|
212
|
+
const out = []
|
|
213
|
+
out.push(`Vault: ${dir}`)
|
|
214
|
+
out.push(`Orquestra: ${remoteCount} entradas · Vault: ${localCount} notas · ${firstRun ? 'Primera sincronización' : `Última sincronización ${ago(lastSyncAt, now)}`}`)
|
|
215
|
+
out.push(apply ? 'Modo: APLICAR CAMBIOS' : 'Modo: VISTA PREVIA -- no se escribe nada todavía')
|
|
216
|
+
if (firstRun) {
|
|
217
|
+
out.push('', 'Cómo funciona:',
|
|
218
|
+
' • Cada nota del vault es una entrada de la base de conocimiento de tu workspace (el nombre del archivo es el título).',
|
|
219
|
+
' • Al sincronizar se añade un bloque de propiedades (frontmatter) con orquestraId; no toques esas líneas.',
|
|
220
|
+
' • NUNCA se borra nada: una nota que borres del vault se vuelve a bajar, y si cambia en los dos lados se guarda una copia .conflict.md.')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
out.push('', `NOTAS -- ${plan.length === 0 ? 'todo al día' : `${plan.length} cambio${plan.length === 1 ? '' : 's'}`}`)
|
|
224
|
+
for (const op of OP_ORDER) {
|
|
225
|
+
const items = plan.filter(p => p.op === op)
|
|
226
|
+
if (!items.length) continue
|
|
227
|
+
out.push(` ${LABEL[op]} (${items.length})`)
|
|
228
|
+
for (const p of items) {
|
|
229
|
+
const why = op === 'conflict' ? `gana ${p.winner === 'local' ? 'la versión del vault' : 'la de Orquestra'}; la otra queda como .conflict.md` : p.reason
|
|
230
|
+
out.push(` • ${itemName(p)}${why ? ` -- ${why}` : ''}`)
|
|
231
|
+
}
|
|
232
|
+
if (op === 'orphan') out.push(' (tiene orquestraId pero esa entrada ya no existe en Orquestra; no se toca. Para crearla de nuevo, borra la línea orquestraId del archivo.)')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (att.enabled) {
|
|
236
|
+
const nothing = !att.uploads.length && !att.downloads.length && !att.missing.length && !att.unsupported.length && !att.toolarge.length
|
|
237
|
+
out.push('', `IMÁGENES Y VIDEOS -- ${nothing ? (att.unchanged ? `${att.unchanged} ya sincronizados` : 'ninguno referenciado en las notas') : 'cambios'}`)
|
|
238
|
+
if (att.uploads.length) {
|
|
239
|
+
const bytes = att.uploads.reduce((a, u) => a + (u.size || 0), 0)
|
|
240
|
+
out.push(` ↑ Subir a Orquestra (${att.uploads.length}, ${formatBytes(bytes)})`)
|
|
241
|
+
for (const u of att.uploads) out.push(` • ${u.rel} (${formatBytes(u.size || 0)}, en "${u.title}"${u.status === 'replace' ? ', cambió: se reemplaza la versión anterior' : ''})`)
|
|
242
|
+
}
|
|
243
|
+
if (att.downloads.length) {
|
|
244
|
+
out.push(` ↓ Bajar al vault (${att.downloads.length})`)
|
|
245
|
+
for (const d of att.downloads) out.push(` • ${d.rel} (en "${d.title}")`)
|
|
246
|
+
}
|
|
247
|
+
if (att.unchanged && !nothing) out.push(` ✓ ${att.unchanged} ya sincronizados`)
|
|
248
|
+
if (att.missing.length) {
|
|
249
|
+
out.push(` ✗ Referenciados pero no existen en el vault (${att.missing.length}) -- revisa el nombre o la ruta`)
|
|
250
|
+
for (const m of att.missing) out.push(` • ${m.rel || m.ref} (en "${m.title}")`)
|
|
251
|
+
}
|
|
252
|
+
if (att.unsupported.length) {
|
|
253
|
+
out.push(` – No se sincronizan (${att.unsupported.length}): solo png/jpg/gif/webp y mp4/mov/webm`)
|
|
254
|
+
for (const m of att.unsupported) out.push(` • ${m.rel} (en "${m.title}")`)
|
|
255
|
+
}
|
|
256
|
+
if (att.toolarge.length) {
|
|
257
|
+
out.push(` – Demasiado grandes (${att.toolarge.length})`)
|
|
258
|
+
for (const m of att.toolarge) out.push(` • ${m.rel} (${formatBytes(m.size || 0)}, máximo ${m.maxMb} MB)`)
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
out.push('', 'IMÁGENES Y VIDEOS -- omitidos (--no-attachments)')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const total = plan.length + (att.uploads?.length || 0) + (att.downloads?.length || 0)
|
|
265
|
+
out.push('')
|
|
266
|
+
if (!apply) {
|
|
267
|
+
if (total) out.push(`Siguiente paso: revisa la lista y aplica con\n orquestra-mcp vault sync ${dirArg} --apply`)
|
|
268
|
+
else out.push('Nada que hacer: el vault y Orquestra están igual.')
|
|
269
|
+
}
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Resumen final de lo que SÍ se hizo. */
|
|
274
|
+
export function renderSummary(c) {
|
|
275
|
+
const parts = []
|
|
276
|
+
const add = (n, singular, plural) => { if (n) parts.push(`${n} ${n === 1 ? singular : plural}`) }
|
|
277
|
+
add(c.pull, 'nota bajada', 'notas bajadas')
|
|
278
|
+
add(c.push, 'nota subida', 'notas subidas')
|
|
279
|
+
add(c.create, 'nota creada en Orquestra', 'notas creadas en Orquestra')
|
|
280
|
+
add(c.conflict, 'conflicto resuelto', 'conflictos resueltos')
|
|
281
|
+
add(c.up, 'archivo subido', 'archivos subidos')
|
|
282
|
+
add(c.down, 'archivo bajado', 'archivos bajados')
|
|
283
|
+
const lines = []
|
|
284
|
+
lines.push(c.failed ? `Listo con ${c.failed} error(es) -- vuelve a correr el comando para reintentar solo lo que falló.` : parts.length ? `✓ Listo: ${parts.join(', ')}.` : '✓ Listo: no había nada que cambiar.')
|
|
285
|
+
if (c.conflict) lines.push(' Revisa las copias *.conflict.md junto a cada nota en conflicto; cuando termines puedes borrarlas.')
|
|
286
|
+
lines.push(' El estado quedó en .orquestra-sync.json (no lo borres ni lo edites). La próxima vez solo se tocará lo que cambie.')
|
|
287
|
+
return lines
|
|
288
|
+
}
|
|
289
|
+
|
|
172
290
|
// ── Comando ──────────────────────────────────────────────────────────────────
|
|
173
291
|
|
|
174
|
-
const
|
|
292
|
+
const HELP = [
|
|
293
|
+
'Uso: orquestra-mcp vault sync <carpeta-del-vault> [opciones]',
|
|
294
|
+
'',
|
|
295
|
+
'Sincroniza la base de conocimiento de tu workspace con una carpeta de Obsidian, en los dos sentidos.',
|
|
296
|
+
'Córrelo desde la carpeta de un proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).',
|
|
297
|
+
'',
|
|
298
|
+
'Opciones:',
|
|
299
|
+
' --apply escribe los cambios (la primera vez, sin esta opción solo se muestra el plan)',
|
|
300
|
+
' --dry-run solo muestra el plan, aunque ya hayas sincronizado antes',
|
|
301
|
+
' --no-attachments no sincroniza imágenes ni videos (solo el texto de las notas)',
|
|
302
|
+
' --verbose también lista lo que ya está al día',
|
|
303
|
+
' --help muestra esta ayuda',
|
|
304
|
+
'',
|
|
305
|
+
'Qué sincroniza: notas .md (el nombre del archivo es el título, el frontmatter guarda orquestraId y tags) y las',
|
|
306
|
+
'imágenes/videos (png, jpg, gif, webp, mp4, mov, webm) que las notas incrustan con ![[archivo]] o .',
|
|
307
|
+
'Nunca borra nada. Si una nota cambia en los dos lados, gana la más reciente y la otra queda como .conflict.md.',
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
/** Reparte `items` en `limit` tareas a la vez. */
|
|
311
|
+
async function mapLimit(items, limit, fn) {
|
|
312
|
+
const out = new Array(items.length)
|
|
313
|
+
let next = 0
|
|
314
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
315
|
+
while (next < items.length) { const i = next++; out[i] = await fn(items[i], i) }
|
|
316
|
+
}))
|
|
317
|
+
return out
|
|
318
|
+
}
|
|
175
319
|
|
|
176
320
|
export async function runVaultSync(argv) {
|
|
177
321
|
const dirArg = argv.find(a => !a.startsWith('--'))
|
|
178
|
-
if (!dirArg) {
|
|
179
|
-
console.log('Uso: orquestra-mcp vault sync <carpeta-del-vault> [--apply | --dry-run]\n' +
|
|
180
|
-
'Sincroniza la base de conocimiento del workspace con esa carpeta. Córrelo desde la carpeta de un\n' +
|
|
181
|
-
'proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).')
|
|
182
|
-
return
|
|
183
|
-
}
|
|
322
|
+
if (!dirArg || argv.includes('--help')) { console.log(HELP.join('\n')); return }
|
|
184
323
|
const dir = path.resolve(dirArg)
|
|
185
324
|
await fs.mkdir(dir, { recursive: true })
|
|
186
325
|
const config = loadConfig()
|
|
@@ -193,19 +332,82 @@ export async function runVaultSync(argv) {
|
|
|
193
332
|
}
|
|
194
333
|
// Primera vez: solo muestra el plan, salvo --apply explícito.
|
|
195
334
|
const apply = argv.includes('--apply') || (!!state && !argv.includes('--dry-run'))
|
|
335
|
+
const withAtt = !argv.includes('--no-attachments')
|
|
336
|
+
const verbose = argv.includes('--verbose')
|
|
196
337
|
|
|
338
|
+
console.log('Leyendo Orquestra y el vault...')
|
|
197
339
|
const { items: remote } = await apiGet(config, '/v1/knowledge', { projectId, full: 'true' })
|
|
198
|
-
const { notes: local
|
|
340
|
+
const { notes: local } = await readLocal(dir)
|
|
199
341
|
const plan = planSync({ remote, local, state })
|
|
342
|
+
const remoteIds = new Set(remote.map(r => r.id))
|
|
200
343
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
return
|
|
344
|
+
// ── Adjuntos: qué subir (según el vault) y qué bajar (según Orquestra) ──
|
|
345
|
+
const attState = structuredClone(state?.attachments || {})
|
|
346
|
+
const remoteCache = new Map()
|
|
347
|
+
const remoteFor = async id => {
|
|
348
|
+
if (!remoteCache.has(id)) remoteCache.set(id, await listRemoteAttachments(config, projectId, id).catch(() => new Map()))
|
|
349
|
+
return remoteCache.get(id)
|
|
350
|
+
}
|
|
351
|
+
const att = { enabled: withAtt, uploads: [], downloads: [], unchanged: 0, missing: [], unsupported: [], toolarge: [] }
|
|
352
|
+
|
|
353
|
+
async function planUploads(notes) {
|
|
354
|
+
// Las huérfanas (orquestraId que ya no existe) no se tocan; las nuevas (sin id) se suben al crearse.
|
|
355
|
+
const eligible = notes.filter(n => !n.id || remoteIds.has(n.id) || n.created)
|
|
356
|
+
const rels = new Set()
|
|
357
|
+
for (const n of eligible) for (const ref of embedRefs(n.body)) rels.add(ref)
|
|
358
|
+
const index = buildFileIndex(await scanVaultFiles(dir))
|
|
359
|
+
const referenced = []
|
|
360
|
+
for (const n of eligible) for (const ref of embedRefs(n.body)) { const rel = resolveRef(ref, n.file, index); if (rel) referenced.push(rel) }
|
|
361
|
+
const files = await fileInfoFor(dir, referenced)
|
|
362
|
+
const items = planAttachmentUploads({ notes: eligible, files, index, attState })
|
|
363
|
+
// Sin estado (se perdió el .orquestra-sync.json) pero ya subido: si Orquestra tiene el mismo archivo del mismo tamaño, se adopta en vez de duplicar.
|
|
364
|
+
await mapLimit(items.filter(i => i.status === 'upload' && i.note.id), 4, async it => {
|
|
365
|
+
const r = (await remoteFor(it.note.id)).get(it.rel)
|
|
366
|
+
if (r && r.fileSize === it.size) { it.status = 'adopt'; it.adoptId = r.id }
|
|
367
|
+
})
|
|
368
|
+
return items
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const classify = items => {
|
|
372
|
+
att.uploads = []; att.unchanged = 0; att.missing = []; att.unsupported = []; att.toolarge = []
|
|
373
|
+
for (const it of items) {
|
|
374
|
+
const row = { rel: it.rel, ref: it.ref, title: it.note.title, size: it.size, status: it.status, maxMb: it.maxMb }
|
|
375
|
+
if (it.status === 'upload' || it.status === 'replace') att.uploads.push(row)
|
|
376
|
+
else if (it.status === 'unchanged' || it.status === 'adopt') att.unchanged++
|
|
377
|
+
else if (it.status === 'missing') att.missing.push(row)
|
|
378
|
+
else if (it.status === 'unsupported') att.unsupported.push(row)
|
|
379
|
+
else if (it.status === 'toolarge') att.toolarge.push(row)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function planDownloads(entryIds, titleOf) {
|
|
384
|
+
const rows = []
|
|
385
|
+
await mapLimit(entryIds, 4, async id => {
|
|
386
|
+
for (const a of (await remoteFor(id)).values()) {
|
|
387
|
+
if (!isSafeRelPath(a.fileName)) continue
|
|
388
|
+
if (await fs.access(path.join(dir, a.fileName)).then(() => true, () => false)) continue
|
|
389
|
+
rows.push({ id, rel: a.fileName, title: titleOf(id), attachment: a })
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
return rows
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let uploadItems = []
|
|
396
|
+
let pullIds = []
|
|
397
|
+
if (withAtt) {
|
|
398
|
+
uploadItems = await planUploads(local)
|
|
399
|
+
classify(uploadItems)
|
|
400
|
+
pullIds = [...new Set(plan.filter(p => p.op === 'pull' || (p.op === 'conflict' && p.winner === 'remote')).map(p => p.remote.id))]
|
|
401
|
+
const titles = new Map(remote.map(r => [r.id, r.title]))
|
|
402
|
+
att.downloads = (await planDownloads(pullIds, id => titles.get(id) || id)).map(({ rel, title }) => ({ rel, title }))
|
|
207
403
|
}
|
|
208
404
|
|
|
405
|
+
for (const line of renderReport({ dir, dirArg, apply, firstRun: !state, lastSyncAt: state?.syncedAt, remoteCount: remote.length, localCount: local.length, plan, att, verbose })) console.log(line)
|
|
406
|
+
if (verbose && state) console.log(`\n(${local.length - plan.filter(p => p.local).length} notas sin cambios)`)
|
|
407
|
+
if (!apply) return
|
|
408
|
+
|
|
409
|
+
// ── Aplicar ─────────────────────────────────────────────────────────────
|
|
410
|
+
console.log('\nAplicando...')
|
|
209
411
|
const entries = { ...(state?.entries || {}) }
|
|
210
412
|
const taken = new Set(local.map(l => path.basename(l.file).toLowerCase()))
|
|
211
413
|
const writeRemote = async (r, file, extra = []) => {
|
|
@@ -219,17 +421,22 @@ export async function runVaultSync(argv) {
|
|
|
219
421
|
const saved = id ? await apiPatch(config, '/v1/knowledge', { id, ...body }) : await apiPost(config, '/v1/knowledge', body)
|
|
220
422
|
// Reescribe el frontmatter con el id/updatedAt nuevos, sin tocar el texto.
|
|
221
423
|
await writeRemote({ ...saved, title: l.title, body: l.body, tags: saved.tags || l.tags }, l.file, l.extra)
|
|
424
|
+
return saved.id || id
|
|
222
425
|
}
|
|
223
426
|
const saveConflictCopy = async (file, text) => {
|
|
224
427
|
await fs.writeFile(path.join(dir, file.replace(/\.md$/, CONFLICT_SUFFIX)), text)
|
|
225
428
|
}
|
|
226
429
|
|
|
227
|
-
|
|
430
|
+
const done = { pull: 0, push: 0, create: 0, conflict: 0, up: 0, down: 0, failed: 0 }
|
|
431
|
+
const createdIds = new Set()
|
|
432
|
+
let n = 0
|
|
228
433
|
for (const p of plan) {
|
|
434
|
+
n++
|
|
435
|
+
if (p.op === 'orphan') continue
|
|
229
436
|
try {
|
|
230
437
|
if (p.op === 'pull') await writeRemote(p.remote, p.local?.file || entries[p.remote.id]?.file || uniqueFile(p.remote.title, taken), p.local?.extra)
|
|
231
438
|
else if (p.op === 'push') await push(p.local, p.remote.id)
|
|
232
|
-
else if (p.op === 'create') await push(p.local, null)
|
|
439
|
+
else if (p.op === 'create') createdIds.add(await push(p.local, null))
|
|
233
440
|
else if (p.op === 'conflict') {
|
|
234
441
|
if (p.winner === 'local') {
|
|
235
442
|
await saveConflictCopy(p.local.file, serializeNote({ id: p.remote.id, title: p.remote.title, fileTitle: '', tags: p.remote.tags || [], body: p.remote.body || '' }))
|
|
@@ -239,11 +446,55 @@ export async function runVaultSync(argv) {
|
|
|
239
446
|
await writeRemote(p.remote, p.local.file, p.local.extra)
|
|
240
447
|
}
|
|
241
448
|
}
|
|
449
|
+
done[p.op]++
|
|
450
|
+
console.log(` [${n}/${plan.length}] ✓ ${DONE_LABEL[p.op]} -- ${itemName(p)}`)
|
|
242
451
|
} catch (err) {
|
|
243
|
-
failed++
|
|
244
|
-
console.error(`
|
|
452
|
+
done.failed++
|
|
453
|
+
console.error(` [${n}/${plan.length}] ✗ ${itemName(p)}: ${err.message}`)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (withAtt) {
|
|
458
|
+
// Con las notas ya escritas (ids nuevos, cuerpos de Orquestra) se recalcula qué subir.
|
|
459
|
+
const { notes: after } = await readLocal(dir)
|
|
460
|
+
const items = await planUploads(after.map(x => ({ ...x, created: createdIds.has(x.id) })))
|
|
461
|
+
const todo = items.filter(i => ['upload', 'replace', 'adopt'].includes(i.status) && i.note.id)
|
|
462
|
+
let k = 0
|
|
463
|
+
for (const it of todo) {
|
|
464
|
+
k++
|
|
465
|
+
try {
|
|
466
|
+
const bucket = (attState[it.note.id] ||= {})
|
|
467
|
+
if (it.status === 'adopt') {
|
|
468
|
+
bucket[it.rel] = { attachmentId: it.adoptId, hash: it.hash, size: it.size }
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
bucket[it.rel] = await uploadAttachment(config, projectId, it.note.id, dir, it)
|
|
472
|
+
done.up++
|
|
473
|
+
console.log(` [${k}/${todo.length}] ✓ ↑ ${it.rel}${it.status === 'replace' ? ' (reemplazó la versión anterior)' : ''}`)
|
|
474
|
+
} catch (err) {
|
|
475
|
+
done.failed++
|
|
476
|
+
console.error(` [${k}/${todo.length}] ✗ ${it.rel}: ${err.message}`)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const idsToFetch = [...new Set([...pullIds, ...createdIds])]
|
|
480
|
+
const titles = new Map(after.map(x => [x.id, x.title]))
|
|
481
|
+
const downloads = await planDownloads(idsToFetch, id => titles.get(id) || id)
|
|
482
|
+
for (const d of downloads) {
|
|
483
|
+
try {
|
|
484
|
+
if (await downloadAttachment(config, projectId, dir, d.attachment)) {
|
|
485
|
+
done.down++
|
|
486
|
+
const info = (await fileInfoFor(dir, [d.rel])).get(d.rel)
|
|
487
|
+
if (info) (attState[d.id] ||= {})[d.rel] = { attachmentId: d.attachment.id, hash: info.hash, size: info.size }
|
|
488
|
+
console.log(` ✓ ↓ ${d.rel}`)
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
done.failed++
|
|
492
|
+
console.error(` ✗ ${d.rel}: ${err.message}`)
|
|
493
|
+
}
|
|
245
494
|
}
|
|
246
495
|
}
|
|
247
|
-
|
|
248
|
-
|
|
496
|
+
|
|
497
|
+
await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries, attachments: attState }, null, 2))
|
|
498
|
+
console.log('')
|
|
499
|
+
for (const line of renderSummary(done)) console.log(line)
|
|
249
500
|
}
|