@cocaxcode/logbook-mcp 0.4.12 → 0.5.1
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 +79 -25
- package/dist/{cli-FYV7XES5.js → cli-AIBPTLXG.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/server-H4RARRWW.js +2773 -0
- package/package.json +2 -2
- package/dist/server-YCNK5TAH.js +0 -1162
|
@@ -0,0 +1,2773 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
|
|
6
|
+
// src/tools/topics.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/db/connection.ts
|
|
10
|
+
import Database from "better-sqlite3";
|
|
11
|
+
import { existsSync, mkdirSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { dirname, join } from "path";
|
|
14
|
+
|
|
15
|
+
// src/db/schema.ts
|
|
16
|
+
var SCHEMA_SQL = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
name TEXT NOT NULL UNIQUE,
|
|
20
|
+
path TEXT NOT NULL UNIQUE,
|
|
21
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
name TEXT NOT NULL UNIQUE,
|
|
27
|
+
description TEXT,
|
|
28
|
+
commit_prefix TEXT,
|
|
29
|
+
is_custom INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
repo_id INTEGER REFERENCES repos(id) ON DELETE SET NULL,
|
|
36
|
+
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
|
|
37
|
+
content TEXT NOT NULL,
|
|
38
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
repo_id INTEGER REFERENCES repos(id) ON DELETE SET NULL,
|
|
44
|
+
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
47
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
48
|
+
remind_at TEXT,
|
|
49
|
+
remind_pattern TEXT,
|
|
50
|
+
remind_last_done TEXT,
|
|
51
|
+
completed_at TEXT,
|
|
52
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_notes_repo ON notes(repo_id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topic_id);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_notes_date ON notes(created_at);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_todos_repo ON todos(repo_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_todos_topic ON todos(topic_id);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_todos_date ON todos(created_at);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS code_todo_snapshots (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
|
65
|
+
file TEXT NOT NULL,
|
|
66
|
+
line INTEGER NOT NULL,
|
|
67
|
+
tag TEXT NOT NULL,
|
|
68
|
+
content TEXT NOT NULL,
|
|
69
|
+
topic_name TEXT NOT NULL,
|
|
70
|
+
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
71
|
+
resolved_at TEXT,
|
|
72
|
+
UNIQUE(repo_id, file, content)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_code_snapshots_repo ON code_todo_snapshots(repo_id);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_code_snapshots_resolved ON code_todo_snapshots(resolved_at);
|
|
77
|
+
|
|
78
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
79
|
+
content,
|
|
80
|
+
content='notes',
|
|
81
|
+
content_rowid='id'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS todos_fts USING fts5(
|
|
85
|
+
content,
|
|
86
|
+
content='todos',
|
|
87
|
+
content_rowid='id'
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
|
91
|
+
INSERT INTO notes_fts(rowid, content) VALUES (new.id, new.content);
|
|
92
|
+
END;
|
|
93
|
+
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
|
|
94
|
+
INSERT INTO notes_fts(notes_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
95
|
+
END;
|
|
96
|
+
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|
97
|
+
INSERT INTO notes_fts(notes_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
98
|
+
INSERT INTO notes_fts(rowid, content) VALUES (new.id, new.content);
|
|
99
|
+
END;
|
|
100
|
+
|
|
101
|
+
CREATE TRIGGER IF NOT EXISTS todos_ai AFTER INSERT ON todos BEGIN
|
|
102
|
+
INSERT INTO todos_fts(rowid, content) VALUES (new.id, new.content);
|
|
103
|
+
END;
|
|
104
|
+
CREATE TRIGGER IF NOT EXISTS todos_ad AFTER DELETE ON todos BEGIN
|
|
105
|
+
INSERT INTO todos_fts(todos_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
106
|
+
END;
|
|
107
|
+
CREATE TRIGGER IF NOT EXISTS todos_au AFTER UPDATE ON todos BEGIN
|
|
108
|
+
INSERT INTO todos_fts(todos_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
109
|
+
INSERT INTO todos_fts(rowid, content) VALUES (new.id, new.content);
|
|
110
|
+
END;
|
|
111
|
+
`;
|
|
112
|
+
var MIGRATIONS_SQL = `
|
|
113
|
+
-- Add reminder columns to existing todos tables (safe to run multiple times)
|
|
114
|
+
ALTER TABLE todos ADD COLUMN remind_at TEXT;
|
|
115
|
+
ALTER TABLE todos ADD COLUMN remind_pattern TEXT;
|
|
116
|
+
ALTER TABLE todos ADD COLUMN remind_last_done TEXT;
|
|
117
|
+
`;
|
|
118
|
+
var POST_MIGRATION_SQL = `
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_todos_remind ON todos(remind_at);
|
|
120
|
+
`;
|
|
121
|
+
var SEED_TOPICS_SQL = `
|
|
122
|
+
INSERT OR IGNORE INTO topics (name, description, commit_prefix, is_custom) VALUES
|
|
123
|
+
('feature', 'Funcionalidad nueva', 'feat', 0),
|
|
124
|
+
('fix', 'Correcci\xF3n de errores', 'fix', 0),
|
|
125
|
+
('chore', 'Mantenimiento general', 'refactor,docs,ci,build,chore,test,perf', 0),
|
|
126
|
+
('idea', 'Ideas y propuestas futuras', NULL, 0),
|
|
127
|
+
('decision', 'Decisiones tomadas', NULL, 0),
|
|
128
|
+
('blocker', 'Bloqueos activos', NULL, 0),
|
|
129
|
+
('reminder', 'Recordatorios con fecha', NULL, 0);
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
// src/db/connection.ts
|
|
133
|
+
var DB_DIR = join(homedir(), ".logbook");
|
|
134
|
+
var DB_PATH = join(DB_DIR, "logbook.db");
|
|
135
|
+
var db = null;
|
|
136
|
+
function getDb(dbPath) {
|
|
137
|
+
if (db) return db;
|
|
138
|
+
const path = dbPath ?? DB_PATH;
|
|
139
|
+
const dir = dirname(path);
|
|
140
|
+
if (!existsSync(dir)) {
|
|
141
|
+
mkdirSync(dir, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
db = new Database(path);
|
|
144
|
+
db.pragma("journal_mode = WAL");
|
|
145
|
+
db.pragma("foreign_keys = ON");
|
|
146
|
+
db.exec(SCHEMA_SQL);
|
|
147
|
+
for (const stmt of MIGRATIONS_SQL.split(";").map((s) => s.trim()).filter(Boolean)) {
|
|
148
|
+
try {
|
|
149
|
+
db.exec(stmt);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
152
|
+
if (!msg.includes("duplicate column")) {
|
|
153
|
+
console.error(`Migration warning: ${msg}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
db.exec(POST_MIGRATION_SQL);
|
|
158
|
+
db.exec(SEED_TOPICS_SQL);
|
|
159
|
+
return db;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/db/queries.ts
|
|
163
|
+
function insertRepo(db2, name, path) {
|
|
164
|
+
const stmt = db2.prepare(
|
|
165
|
+
"INSERT INTO repos (name, path) VALUES (?, ?)"
|
|
166
|
+
);
|
|
167
|
+
const result = stmt.run(name, path);
|
|
168
|
+
return db2.prepare("SELECT * FROM repos WHERE id = ?").get(result.lastInsertRowid);
|
|
169
|
+
}
|
|
170
|
+
function getRepoByPath(db2, path) {
|
|
171
|
+
return db2.prepare("SELECT * FROM repos WHERE path = ?").get(path);
|
|
172
|
+
}
|
|
173
|
+
function getRepoByName(db2, name) {
|
|
174
|
+
return db2.prepare("SELECT * FROM repos WHERE name = ?").get(name);
|
|
175
|
+
}
|
|
176
|
+
function getTopicByName(db2, name) {
|
|
177
|
+
return db2.prepare("SELECT * FROM topics WHERE name = ?").get(name);
|
|
178
|
+
}
|
|
179
|
+
function getAllTopics(db2) {
|
|
180
|
+
return db2.prepare("SELECT * FROM topics ORDER BY is_custom, name").all();
|
|
181
|
+
}
|
|
182
|
+
function insertTopic(db2, name, description) {
|
|
183
|
+
const stmt = db2.prepare(
|
|
184
|
+
"INSERT INTO topics (name, description, is_custom) VALUES (?, ?, 1)"
|
|
185
|
+
);
|
|
186
|
+
const result = stmt.run(name, description ?? null);
|
|
187
|
+
return db2.prepare("SELECT * FROM topics WHERE id = ?").get(result.lastInsertRowid);
|
|
188
|
+
}
|
|
189
|
+
var NOTE_WITH_META_SQL = `
|
|
190
|
+
SELECT n.*, r.name as repo_name, t.name as topic_name
|
|
191
|
+
FROM notes n
|
|
192
|
+
LEFT JOIN repos r ON n.repo_id = r.id
|
|
193
|
+
LEFT JOIN topics t ON n.topic_id = t.id
|
|
194
|
+
`;
|
|
195
|
+
function insertNote(db2, repoId, topicId, content) {
|
|
196
|
+
const stmt = db2.prepare(
|
|
197
|
+
"INSERT INTO notes (repo_id, topic_id, content) VALUES (?, ?, ?)"
|
|
198
|
+
);
|
|
199
|
+
const result = stmt.run(repoId, topicId, content);
|
|
200
|
+
return db2.prepare(`${NOTE_WITH_META_SQL} WHERE n.id = ?`).get(result.lastInsertRowid);
|
|
201
|
+
}
|
|
202
|
+
function getNotes(db2, filters = {}) {
|
|
203
|
+
const conditions = [];
|
|
204
|
+
const params = [];
|
|
205
|
+
if (filters.repoId !== void 0) {
|
|
206
|
+
conditions.push("n.repo_id = ?");
|
|
207
|
+
params.push(filters.repoId);
|
|
208
|
+
}
|
|
209
|
+
if (filters.topicId !== void 0) {
|
|
210
|
+
conditions.push("n.topic_id = ?");
|
|
211
|
+
params.push(filters.topicId);
|
|
212
|
+
}
|
|
213
|
+
if (filters.from) {
|
|
214
|
+
conditions.push("n.created_at >= ?");
|
|
215
|
+
params.push(filters.from);
|
|
216
|
+
}
|
|
217
|
+
if (filters.to) {
|
|
218
|
+
conditions.push("n.created_at < ?");
|
|
219
|
+
params.push(filters.to);
|
|
220
|
+
}
|
|
221
|
+
const where = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
222
|
+
const limit = filters.limit ?? 100;
|
|
223
|
+
return db2.prepare(
|
|
224
|
+
`${NOTE_WITH_META_SQL}${where} ORDER BY n.created_at DESC LIMIT ?`
|
|
225
|
+
).all(...params, limit);
|
|
226
|
+
}
|
|
227
|
+
var TODO_WITH_META_SQL = `
|
|
228
|
+
SELECT t.*, r.name as repo_name, tp.name as topic_name, 'manual' as source
|
|
229
|
+
FROM todos t
|
|
230
|
+
LEFT JOIN repos r ON t.repo_id = r.id
|
|
231
|
+
LEFT JOIN topics tp ON t.topic_id = tp.id
|
|
232
|
+
`;
|
|
233
|
+
function insertTodo(db2, repoId, topicId, content, priority = "normal", remindAt, remindPattern) {
|
|
234
|
+
const stmt = db2.prepare(
|
|
235
|
+
"INSERT INTO todos (repo_id, topic_id, content, priority, remind_at, remind_pattern) VALUES (?, ?, ?, ?, ?, ?)"
|
|
236
|
+
);
|
|
237
|
+
const result = stmt.run(repoId, topicId, content, priority, remindAt ?? null, remindPattern ?? null);
|
|
238
|
+
return db2.prepare(`${TODO_WITH_META_SQL} WHERE t.id = ?`).get(result.lastInsertRowid);
|
|
239
|
+
}
|
|
240
|
+
function getTodos(db2, filters = {}) {
|
|
241
|
+
const conditions = [];
|
|
242
|
+
const params = [];
|
|
243
|
+
if (filters.status && filters.status !== "all") {
|
|
244
|
+
conditions.push("t.status = ?");
|
|
245
|
+
params.push(filters.status);
|
|
246
|
+
}
|
|
247
|
+
if (filters.repoId !== void 0) {
|
|
248
|
+
conditions.push("t.repo_id = ?");
|
|
249
|
+
params.push(filters.repoId);
|
|
250
|
+
}
|
|
251
|
+
if (filters.topicId !== void 0) {
|
|
252
|
+
conditions.push("t.topic_id = ?");
|
|
253
|
+
params.push(filters.topicId);
|
|
254
|
+
}
|
|
255
|
+
if (filters.priority) {
|
|
256
|
+
conditions.push("t.priority = ?");
|
|
257
|
+
params.push(filters.priority);
|
|
258
|
+
}
|
|
259
|
+
if (filters.from) {
|
|
260
|
+
conditions.push("t.created_at >= ?");
|
|
261
|
+
params.push(filters.from);
|
|
262
|
+
}
|
|
263
|
+
if (filters.to) {
|
|
264
|
+
conditions.push("t.created_at < ?");
|
|
265
|
+
params.push(filters.to);
|
|
266
|
+
}
|
|
267
|
+
const where = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
268
|
+
const limit = filters.limit ?? 100;
|
|
269
|
+
return db2.prepare(
|
|
270
|
+
`${TODO_WITH_META_SQL}${where} ORDER BY t.created_at DESC LIMIT ?`
|
|
271
|
+
).all(...params, limit);
|
|
272
|
+
}
|
|
273
|
+
function updateTodoStatus(db2, ids, status) {
|
|
274
|
+
if (ids.length === 0) return [];
|
|
275
|
+
const completedAt = status === "done" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
276
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
277
|
+
db2.prepare(
|
|
278
|
+
`UPDATE todos SET status = ?, completed_at = ? WHERE id IN (${placeholders})`
|
|
279
|
+
).run(status, completedAt, ...ids);
|
|
280
|
+
return db2.prepare(
|
|
281
|
+
`${TODO_WITH_META_SQL} WHERE t.id IN (${placeholders})`
|
|
282
|
+
).all(...ids);
|
|
283
|
+
}
|
|
284
|
+
function updateTodo(db2, id, fields) {
|
|
285
|
+
const sets = [];
|
|
286
|
+
const params = [];
|
|
287
|
+
if (fields.content !== void 0) {
|
|
288
|
+
sets.push("content = ?");
|
|
289
|
+
params.push(fields.content);
|
|
290
|
+
}
|
|
291
|
+
if (fields.topicId !== void 0) {
|
|
292
|
+
sets.push("topic_id = ?");
|
|
293
|
+
params.push(fields.topicId);
|
|
294
|
+
}
|
|
295
|
+
if (fields.priority !== void 0) {
|
|
296
|
+
sets.push("priority = ?");
|
|
297
|
+
params.push(fields.priority);
|
|
298
|
+
}
|
|
299
|
+
if (sets.length === 0) return void 0;
|
|
300
|
+
params.push(id);
|
|
301
|
+
db2.prepare(`UPDATE todos SET ${sets.join(", ")} WHERE id = ?`).run(
|
|
302
|
+
...params
|
|
303
|
+
);
|
|
304
|
+
return db2.prepare(`${TODO_WITH_META_SQL} WHERE t.id = ?`).get(id);
|
|
305
|
+
}
|
|
306
|
+
function deleteTodos(db2, ids) {
|
|
307
|
+
if (ids.length === 0) return [];
|
|
308
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
309
|
+
const existing = db2.prepare(`SELECT id FROM todos WHERE id IN (${placeholders})`).all(...ids);
|
|
310
|
+
const existingIds = existing.map((r) => r.id);
|
|
311
|
+
if (existingIds.length > 0) {
|
|
312
|
+
const ep = existingIds.map(() => "?").join(",");
|
|
313
|
+
db2.prepare(`DELETE FROM todos WHERE id IN (${ep})`).run(
|
|
314
|
+
...existingIds
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return existingIds;
|
|
318
|
+
}
|
|
319
|
+
function searchNotes(db2, query, filters = {}) {
|
|
320
|
+
const conditions = ["notes_fts MATCH ?"];
|
|
321
|
+
const params = [sanitizeFts(query)];
|
|
322
|
+
if (filters.repoId !== void 0) {
|
|
323
|
+
conditions.push("n.repo_id = ?");
|
|
324
|
+
params.push(filters.repoId);
|
|
325
|
+
}
|
|
326
|
+
if (filters.topicId !== void 0) {
|
|
327
|
+
conditions.push("n.topic_id = ?");
|
|
328
|
+
params.push(filters.topicId);
|
|
329
|
+
}
|
|
330
|
+
const limit = filters.limit ?? 20;
|
|
331
|
+
const where = conditions.join(" AND ");
|
|
332
|
+
return db2.prepare(
|
|
333
|
+
`SELECT n.*, r.name as repo_name, t.name as topic_name, rank
|
|
334
|
+
FROM notes_fts
|
|
335
|
+
JOIN notes n ON n.id = notes_fts.rowid
|
|
336
|
+
LEFT JOIN repos r ON n.repo_id = r.id
|
|
337
|
+
LEFT JOIN topics t ON n.topic_id = t.id
|
|
338
|
+
WHERE ${where}
|
|
339
|
+
ORDER BY rank
|
|
340
|
+
LIMIT ?`
|
|
341
|
+
).all(...params, limit);
|
|
342
|
+
}
|
|
343
|
+
function searchTodos(db2, query, filters = {}) {
|
|
344
|
+
const conditions = ["todos_fts MATCH ?"];
|
|
345
|
+
const params = [sanitizeFts(query)];
|
|
346
|
+
if (filters.repoId !== void 0) {
|
|
347
|
+
conditions.push("t.repo_id = ?");
|
|
348
|
+
params.push(filters.repoId);
|
|
349
|
+
}
|
|
350
|
+
if (filters.topicId !== void 0) {
|
|
351
|
+
conditions.push("t.topic_id = ?");
|
|
352
|
+
params.push(filters.topicId);
|
|
353
|
+
}
|
|
354
|
+
const limit = filters.limit ?? 20;
|
|
355
|
+
const where = conditions.join(" AND ");
|
|
356
|
+
return db2.prepare(
|
|
357
|
+
`SELECT t.*, r.name as repo_name, tp.name as topic_name, 'manual' as source, rank
|
|
358
|
+
FROM todos_fts
|
|
359
|
+
JOIN todos t ON t.id = todos_fts.rowid
|
|
360
|
+
LEFT JOIN repos r ON t.repo_id = r.id
|
|
361
|
+
LEFT JOIN topics tp ON t.topic_id = tp.id
|
|
362
|
+
WHERE ${where}
|
|
363
|
+
ORDER BY rank
|
|
364
|
+
LIMIT ?`
|
|
365
|
+
).all(...params, limit);
|
|
366
|
+
}
|
|
367
|
+
function getCompletedTodos(db2, filters = {}) {
|
|
368
|
+
const conditions = ["t.status = 'done'"];
|
|
369
|
+
const params = [];
|
|
370
|
+
if (filters.repoId !== void 0) {
|
|
371
|
+
conditions.push("t.repo_id = ?");
|
|
372
|
+
params.push(filters.repoId);
|
|
373
|
+
}
|
|
374
|
+
if (filters.topicId !== void 0) {
|
|
375
|
+
conditions.push("t.topic_id = ?");
|
|
376
|
+
params.push(filters.topicId);
|
|
377
|
+
}
|
|
378
|
+
if (filters.from) {
|
|
379
|
+
conditions.push("t.completed_at >= ?");
|
|
380
|
+
params.push(filters.from);
|
|
381
|
+
}
|
|
382
|
+
if (filters.to) {
|
|
383
|
+
conditions.push("t.completed_at < ?");
|
|
384
|
+
params.push(filters.to);
|
|
385
|
+
}
|
|
386
|
+
const where = conditions.join(" AND ");
|
|
387
|
+
return db2.prepare(
|
|
388
|
+
`${TODO_WITH_META_SQL} WHERE ${where} ORDER BY t.completed_at DESC`
|
|
389
|
+
).all(...params);
|
|
390
|
+
}
|
|
391
|
+
function getDueReminders(db2) {
|
|
392
|
+
const now = /* @__PURE__ */ new Date();
|
|
393
|
+
const today = now.toISOString().split("T")[0];
|
|
394
|
+
const tomorrow = new Date(now.getTime() + 864e5).toISOString().split("T")[0];
|
|
395
|
+
const todayItems = db2.prepare(
|
|
396
|
+
`${TODO_WITH_META_SQL} WHERE t.status = 'pending' AND t.remind_at >= ? AND t.remind_at < ? ORDER BY t.remind_at`
|
|
397
|
+
).all(today, tomorrow);
|
|
398
|
+
const overdueItems = db2.prepare(
|
|
399
|
+
`${TODO_WITH_META_SQL} WHERE t.status = 'pending' AND t.remind_at IS NOT NULL AND t.remind_at < ? AND t.remind_pattern IS NULL ORDER BY t.remind_at`
|
|
400
|
+
).all(today);
|
|
401
|
+
const recurringAll = db2.prepare(
|
|
402
|
+
`${TODO_WITH_META_SQL} WHERE t.remind_pattern IS NOT NULL AND (t.remind_last_done IS NULL OR t.remind_last_done < ?)`
|
|
403
|
+
).all(today);
|
|
404
|
+
const recurringToday = recurringAll.filter((r) => matchesPattern(r.remind_pattern, now));
|
|
405
|
+
const hasAny = todayItems.length > 0 || overdueItems.length > 0 || recurringToday.length > 0;
|
|
406
|
+
if (!hasAny) return null;
|
|
407
|
+
return {
|
|
408
|
+
today: groupByRepo(todayItems),
|
|
409
|
+
overdue: groupByRepo(overdueItems),
|
|
410
|
+
recurring: groupByRepo(recurringToday)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function ackRecurringReminder(db2, id) {
|
|
414
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
415
|
+
db2.prepare("UPDATE todos SET remind_last_done = ? WHERE id = ?").run(today, id);
|
|
416
|
+
}
|
|
417
|
+
function matchesPattern(pattern, date) {
|
|
418
|
+
const dayOfWeek = date.getDay();
|
|
419
|
+
const dayOfMonth = date.getDate();
|
|
420
|
+
if (pattern === "daily") return true;
|
|
421
|
+
if (pattern === "weekdays") return dayOfWeek >= 1 && dayOfWeek <= 5;
|
|
422
|
+
if (pattern.startsWith("weekly:")) {
|
|
423
|
+
const days = pattern.slice(7).split(",").map(Number);
|
|
424
|
+
return days.some((d) => d % 7 === dayOfWeek);
|
|
425
|
+
}
|
|
426
|
+
if (pattern.startsWith("monthly:")) {
|
|
427
|
+
const days = pattern.slice(8).split(",").map(Number);
|
|
428
|
+
return days.includes(dayOfMonth);
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function groupByRepo(items) {
|
|
433
|
+
const map = /* @__PURE__ */ new Map();
|
|
434
|
+
for (const item of items) {
|
|
435
|
+
const key = item.repo_name ?? "global";
|
|
436
|
+
if (!map.has(key)) map.set(key, []);
|
|
437
|
+
map.get(key).push(item);
|
|
438
|
+
}
|
|
439
|
+
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([repo_name, reminders]) => ({
|
|
440
|
+
repo_name: repo_name === "global" ? null : repo_name,
|
|
441
|
+
reminders
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
function resolveTopicId(db2, name) {
|
|
445
|
+
const existing = getTopicByName(db2, name);
|
|
446
|
+
if (existing) return existing.id;
|
|
447
|
+
const normalized = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
448
|
+
if (!normalized) return getTopicByName(db2, "chore").id;
|
|
449
|
+
const existingNorm = getTopicByName(db2, normalized);
|
|
450
|
+
if (existingNorm) return existingNorm.id;
|
|
451
|
+
const created = insertTopic(db2, normalized);
|
|
452
|
+
return created.id;
|
|
453
|
+
}
|
|
454
|
+
function syncCodeTodos(db2, repoId, currentTodos) {
|
|
455
|
+
const sync = db2.transaction(() => {
|
|
456
|
+
const existing = db2.prepare("SELECT * FROM code_todo_snapshots WHERE repo_id = ? AND resolved_at IS NULL").all(repoId);
|
|
457
|
+
const currentSet = new Set(currentTodos.map((t) => `${t.file}::${t.content}`));
|
|
458
|
+
let resolved = 0;
|
|
459
|
+
for (const snap of existing) {
|
|
460
|
+
const key = `${snap.file}::${snap.content}`;
|
|
461
|
+
if (!currentSet.has(key)) {
|
|
462
|
+
db2.prepare("UPDATE code_todo_snapshots SET resolved_at = datetime('now') WHERE id = ?").run(snap.id);
|
|
463
|
+
resolved++;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const existingSet = new Set(existing.map((s) => `${s.file}::${s.content}`));
|
|
467
|
+
const allSnapshots = db2.prepare("SELECT file, content FROM code_todo_snapshots WHERE repo_id = ?").all(repoId);
|
|
468
|
+
const allSet = new Set(allSnapshots.map((s) => `${s.file}::${s.content}`));
|
|
469
|
+
let added = 0;
|
|
470
|
+
for (const todo of currentTodos) {
|
|
471
|
+
const key = `${todo.file}::${todo.content}`;
|
|
472
|
+
if (!allSet.has(key)) {
|
|
473
|
+
db2.prepare(
|
|
474
|
+
"INSERT INTO code_todo_snapshots (repo_id, file, line, tag, content, topic_name) VALUES (?, ?, ?, ?, ?, ?)"
|
|
475
|
+
).run(repoId, todo.file, todo.line, todo.tag, todo.content, todo.topic_name);
|
|
476
|
+
added++;
|
|
477
|
+
} else if (existingSet.has(key)) {
|
|
478
|
+
db2.prepare("UPDATE code_todo_snapshots SET line = ? WHERE repo_id = ? AND file = ? AND content = ? AND resolved_at IS NULL").run(todo.line, repoId, todo.file, todo.content);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { added, resolved };
|
|
482
|
+
});
|
|
483
|
+
return sync();
|
|
484
|
+
}
|
|
485
|
+
function getResolvedCodeTodos(db2, repoId, from, to) {
|
|
486
|
+
const conditions = ["repo_id = ?", "resolved_at IS NOT NULL"];
|
|
487
|
+
const params = [repoId];
|
|
488
|
+
if (from) {
|
|
489
|
+
conditions.push("resolved_at >= ?");
|
|
490
|
+
params.push(from);
|
|
491
|
+
}
|
|
492
|
+
if (to) {
|
|
493
|
+
conditions.push("resolved_at < ?");
|
|
494
|
+
params.push(to);
|
|
495
|
+
}
|
|
496
|
+
return db2.prepare(`SELECT * FROM code_todo_snapshots WHERE ${conditions.join(" AND ")} ORDER BY resolved_at DESC`).all(...params);
|
|
497
|
+
}
|
|
498
|
+
function sanitizeFts(query) {
|
|
499
|
+
const words = query.replace(/\0/g, "").split(/\s+/).filter(Boolean).map((word) => word.replace(/"/g, "")).filter(Boolean);
|
|
500
|
+
if (words.length === 0) return '""';
|
|
501
|
+
return words.map((word) => `"${word}"`).join(" ");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/git/detect-repo.ts
|
|
505
|
+
import { execFileSync } from "child_process";
|
|
506
|
+
import { basename } from "path";
|
|
507
|
+
function detectRepoPath() {
|
|
508
|
+
try {
|
|
509
|
+
const result = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
510
|
+
encoding: "utf-8",
|
|
511
|
+
timeout: 5e3,
|
|
512
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
513
|
+
});
|
|
514
|
+
return result.trim().replace(/\\/g, "/");
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/git/code-todos.ts
|
|
521
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
522
|
+
var TAG_TO_TOPIC = {
|
|
523
|
+
TODO: "feature",
|
|
524
|
+
FIXME: "fix",
|
|
525
|
+
BUG: "fix",
|
|
526
|
+
HACK: "chore"
|
|
527
|
+
};
|
|
528
|
+
function scanCodeTodos(repoPath) {
|
|
529
|
+
try {
|
|
530
|
+
const output = execFileSync2(
|
|
531
|
+
"git",
|
|
532
|
+
["-C", repoPath, "grep", "-n", "-E", "(TODO|FIXME|HACK|BUG):", "--no-color"],
|
|
533
|
+
{ encoding: "utf-8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] }
|
|
534
|
+
);
|
|
535
|
+
return parseGitGrepOutput(output);
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function parseGitGrepOutput(output) {
|
|
541
|
+
if (!output.trim()) return [];
|
|
542
|
+
const regex = /^(.+?):(\d+):.*?\b(TODO|FIXME|HACK|BUG):\s*(.+)$/;
|
|
543
|
+
return output.trim().split("\n").filter(Boolean).map((line) => {
|
|
544
|
+
const match = line.match(regex);
|
|
545
|
+
if (!match) return null;
|
|
546
|
+
const [, file, lineNum, tag, content] = match;
|
|
547
|
+
return {
|
|
548
|
+
content: content.trim(),
|
|
549
|
+
source: "code",
|
|
550
|
+
file,
|
|
551
|
+
line: parseInt(lineNum, 10),
|
|
552
|
+
tag,
|
|
553
|
+
topic_name: TAG_TO_TOPIC[tag] ?? "chore"
|
|
554
|
+
};
|
|
555
|
+
}).filter((item) => item !== null);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/storage/sqlite/index.ts
|
|
559
|
+
import { basename as basename2 } from "path";
|
|
560
|
+
function toEntryId(id) {
|
|
561
|
+
return String(id);
|
|
562
|
+
}
|
|
563
|
+
function toNumericId(id) {
|
|
564
|
+
return Number(id);
|
|
565
|
+
}
|
|
566
|
+
function detectWorkspaceFromPath(repoPath) {
|
|
567
|
+
if (!repoPath) {
|
|
568
|
+
return {
|
|
569
|
+
workspace: process.env.LOGBOOK_WORKSPACE || "default",
|
|
570
|
+
project: "global"
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const normalized = repoPath.replace(/\\/g, "/");
|
|
574
|
+
const project = basename2(normalized);
|
|
575
|
+
const projectsIdx = normalized.toLowerCase().indexOf("/projects/");
|
|
576
|
+
if (projectsIdx !== -1) {
|
|
577
|
+
const before = normalized.slice(0, projectsIdx);
|
|
578
|
+
const workspace2 = basename2(before);
|
|
579
|
+
return { workspace: workspace2, project };
|
|
580
|
+
}
|
|
581
|
+
const parts = normalized.split("/");
|
|
582
|
+
const workspace = parts.length >= 2 ? parts[parts.length - 2] : process.env.LOGBOOK_WORKSPACE || "default";
|
|
583
|
+
return { workspace, project };
|
|
584
|
+
}
|
|
585
|
+
function todayDate() {
|
|
586
|
+
return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
587
|
+
}
|
|
588
|
+
var SqliteStorage = class {
|
|
589
|
+
repoPath = null;
|
|
590
|
+
repoId = null;
|
|
591
|
+
// ── Repos ──
|
|
592
|
+
autoRegisterRepo() {
|
|
593
|
+
const db2 = getDb();
|
|
594
|
+
this.repoPath = detectRepoPath();
|
|
595
|
+
if (!this.repoPath) return null;
|
|
596
|
+
const existing = getRepoByPath(db2, this.repoPath);
|
|
597
|
+
if (existing) {
|
|
598
|
+
this.repoId = existing.id;
|
|
599
|
+
return { id: toEntryId(existing.id), name: existing.name, path: existing.path };
|
|
600
|
+
}
|
|
601
|
+
let name = basename2(this.repoPath);
|
|
602
|
+
const nameConflict = getRepoByName(db2, name);
|
|
603
|
+
if (nameConflict) {
|
|
604
|
+
const parts = this.repoPath.split("/");
|
|
605
|
+
const parent = parts.length >= 2 ? parts[parts.length - 2] : "repo";
|
|
606
|
+
name = `${parent}-${name}`;
|
|
607
|
+
}
|
|
608
|
+
const repo = insertRepo(db2, name, this.repoPath);
|
|
609
|
+
this.repoId = repo.id;
|
|
610
|
+
return { id: toEntryId(repo.id), name: repo.name, path: repo.path };
|
|
611
|
+
}
|
|
612
|
+
getWorkspace() {
|
|
613
|
+
if (!this.repoPath) {
|
|
614
|
+
this.repoPath = detectRepoPath();
|
|
615
|
+
}
|
|
616
|
+
return detectWorkspaceFromPath(this.repoPath);
|
|
617
|
+
}
|
|
618
|
+
// ── Notes ──
|
|
619
|
+
insertNote(content, topic) {
|
|
620
|
+
const db2 = getDb();
|
|
621
|
+
this.ensureRepo();
|
|
622
|
+
const topicId = topic ? resolveTopicId(db2, topic) : null;
|
|
623
|
+
const note = insertNote(db2, this.repoId, topicId, content);
|
|
624
|
+
const ws = this.getWorkspace();
|
|
625
|
+
return {
|
|
626
|
+
id: toEntryId(note.id),
|
|
627
|
+
type: "note",
|
|
628
|
+
date: note.created_at,
|
|
629
|
+
project: ws.project,
|
|
630
|
+
workspace: ws.workspace,
|
|
631
|
+
topic: note.topic_name,
|
|
632
|
+
tags: note.topic_name ? [note.topic_name] : [],
|
|
633
|
+
content: note.content
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
getNotes(filters) {
|
|
637
|
+
const db2 = getDb();
|
|
638
|
+
const ws = this.getWorkspace();
|
|
639
|
+
const notes = getNotes(db2, {
|
|
640
|
+
repoId: filters.repoId ? toNumericId(filters.repoId) : this.repoId ?? void 0,
|
|
641
|
+
topicId: filters.topicId ? toNumericId(filters.topicId) : void 0,
|
|
642
|
+
from: filters.from,
|
|
643
|
+
to: filters.to,
|
|
644
|
+
limit: filters.limit
|
|
645
|
+
});
|
|
646
|
+
return notes.map((n) => ({
|
|
647
|
+
id: toEntryId(n.id),
|
|
648
|
+
type: "note",
|
|
649
|
+
date: n.created_at,
|
|
650
|
+
project: ws.project,
|
|
651
|
+
workspace: ws.workspace,
|
|
652
|
+
topic: n.topic_name,
|
|
653
|
+
tags: n.topic_name ? [n.topic_name] : [],
|
|
654
|
+
content: n.content
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
// ── TODOs ──
|
|
658
|
+
insertTodo(content, opts) {
|
|
659
|
+
const db2 = getDb();
|
|
660
|
+
this.ensureRepo();
|
|
661
|
+
const topicId = opts?.topic ? resolveTopicId(db2, opts.topic) : null;
|
|
662
|
+
const todo = insertTodo(
|
|
663
|
+
db2,
|
|
664
|
+
this.repoId,
|
|
665
|
+
topicId,
|
|
666
|
+
content,
|
|
667
|
+
opts?.priority || "normal",
|
|
668
|
+
opts?.remind_at,
|
|
669
|
+
opts?.remind_pattern
|
|
670
|
+
);
|
|
671
|
+
const ws = this.getWorkspace();
|
|
672
|
+
return this.todoToEntry(todo, ws);
|
|
673
|
+
}
|
|
674
|
+
getTodos(filters) {
|
|
675
|
+
const db2 = getDb();
|
|
676
|
+
const ws = this.getWorkspace();
|
|
677
|
+
const todos = getTodos(db2, {
|
|
678
|
+
status: filters.status === "all" ? void 0 : filters.status || "pending",
|
|
679
|
+
repoId: filters.repoId ? toNumericId(filters.repoId) : filters.status !== "all" ? this.repoId ?? void 0 : void 0,
|
|
680
|
+
topicId: filters.topicId ? toNumericId(filters.topicId) : void 0,
|
|
681
|
+
priority: filters.priority,
|
|
682
|
+
from: filters.from,
|
|
683
|
+
to: filters.to,
|
|
684
|
+
limit: filters.limit
|
|
685
|
+
});
|
|
686
|
+
return todos.map((t) => this.todoToEntry(t, ws));
|
|
687
|
+
}
|
|
688
|
+
updateTodoStatus(ids, status) {
|
|
689
|
+
const db2 = getDb();
|
|
690
|
+
const ws = this.getWorkspace();
|
|
691
|
+
const numericIds = ids.map(toNumericId);
|
|
692
|
+
const todos = updateTodoStatus(db2, numericIds, status);
|
|
693
|
+
return todos.map((t) => this.todoToEntry(t, ws));
|
|
694
|
+
}
|
|
695
|
+
updateTodo(id, fields) {
|
|
696
|
+
const db2 = getDb();
|
|
697
|
+
const ws = this.getWorkspace();
|
|
698
|
+
const updateFields = {};
|
|
699
|
+
if (fields.content !== void 0) updateFields.content = fields.content;
|
|
700
|
+
if (fields.topic !== void 0) updateFields.topicId = resolveTopicId(db2, fields.topic);
|
|
701
|
+
if (fields.priority !== void 0) updateFields.priority = fields.priority;
|
|
702
|
+
const todo = updateTodo(db2, toNumericId(id), updateFields);
|
|
703
|
+
if (!todo) return null;
|
|
704
|
+
return this.todoToEntry(todo, ws);
|
|
705
|
+
}
|
|
706
|
+
deleteTodos(ids) {
|
|
707
|
+
const db2 = getDb();
|
|
708
|
+
const numericIds = ids.map(toNumericId);
|
|
709
|
+
const deleted = deleteTodos(db2, numericIds);
|
|
710
|
+
return deleted.map(toEntryId);
|
|
711
|
+
}
|
|
712
|
+
ackRecurringReminder(id) {
|
|
713
|
+
const db2 = getDb();
|
|
714
|
+
ackRecurringReminder(db2, toNumericId(id));
|
|
715
|
+
}
|
|
716
|
+
// ── Specialized entries (stored as notes with topic in SQLite) ──
|
|
717
|
+
insertStandup(yesterday, today, blockers, topic) {
|
|
718
|
+
const content = `## Yesterday
|
|
719
|
+
${yesterday}
|
|
720
|
+
|
|
721
|
+
## Today
|
|
722
|
+
${today}
|
|
723
|
+
|
|
724
|
+
## Blockers
|
|
725
|
+
${blockers || "Ninguno"}`;
|
|
726
|
+
const note = this.insertNote(content, topic || "chore");
|
|
727
|
+
return {
|
|
728
|
+
...note,
|
|
729
|
+
type: "standup",
|
|
730
|
+
yesterday,
|
|
731
|
+
today,
|
|
732
|
+
blockers: blockers || "Ninguno"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
insertDecision(title, context, options, decision, consequences, topic) {
|
|
736
|
+
const optionsList = options.map((o, i) => `${i + 1}. ${o}`).join("\n");
|
|
737
|
+
const content = `# ${title}
|
|
738
|
+
|
|
739
|
+
## Contexto
|
|
740
|
+
${context}
|
|
741
|
+
|
|
742
|
+
## Opciones
|
|
743
|
+
${optionsList}
|
|
744
|
+
|
|
745
|
+
## Decision
|
|
746
|
+
${decision}
|
|
747
|
+
|
|
748
|
+
## Consecuencias
|
|
749
|
+
${consequences}`;
|
|
750
|
+
const note = this.insertNote(content, topic || "decision");
|
|
751
|
+
return {
|
|
752
|
+
...note,
|
|
753
|
+
type: "decision",
|
|
754
|
+
title,
|
|
755
|
+
context,
|
|
756
|
+
options,
|
|
757
|
+
decision,
|
|
758
|
+
consequences
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
insertDebug(title, error, cause, fix, file, topic) {
|
|
762
|
+
const fileLine = file ? `
|
|
763
|
+
|
|
764
|
+
## Archivo
|
|
765
|
+
${file}` : "";
|
|
766
|
+
const content = `# ${title}
|
|
767
|
+
|
|
768
|
+
## Error
|
|
769
|
+
${error}
|
|
770
|
+
|
|
771
|
+
## Causa
|
|
772
|
+
${cause}
|
|
773
|
+
|
|
774
|
+
## Fix
|
|
775
|
+
${fix}${fileLine}`;
|
|
776
|
+
const note = this.insertNote(content, topic || "fix");
|
|
777
|
+
return {
|
|
778
|
+
...note,
|
|
779
|
+
type: "debug",
|
|
780
|
+
title,
|
|
781
|
+
error,
|
|
782
|
+
cause,
|
|
783
|
+
fix,
|
|
784
|
+
attachment: file || null
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// ── Search & queries ──
|
|
788
|
+
search(query, filters) {
|
|
789
|
+
const db2 = getDb();
|
|
790
|
+
const ws = this.getWorkspace();
|
|
791
|
+
const results = [];
|
|
792
|
+
const topicId = filters.topic ? this.resolveTopicSafe(db2, filters.topic) : void 0;
|
|
793
|
+
const repoId = filters.scope === "global" ? void 0 : this.repoId ?? void 0;
|
|
794
|
+
if (!filters.type || filters.type === "all" || filters.type === "notes") {
|
|
795
|
+
const notes = searchNotes(db2, query, { repoId, topicId, limit: filters.limit });
|
|
796
|
+
for (const n of notes) {
|
|
797
|
+
results.push({
|
|
798
|
+
type: "note",
|
|
799
|
+
data: {
|
|
800
|
+
id: toEntryId(n.id),
|
|
801
|
+
type: "note",
|
|
802
|
+
date: n.created_at,
|
|
803
|
+
project: ws.project,
|
|
804
|
+
workspace: ws.workspace,
|
|
805
|
+
topic: n.topic_name,
|
|
806
|
+
tags: n.topic_name ? [n.topic_name] : [],
|
|
807
|
+
content: n.content
|
|
808
|
+
},
|
|
809
|
+
rank: n.rank || 0
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (!filters.type || filters.type === "all" || filters.type === "todos") {
|
|
814
|
+
const todos = searchTodos(db2, query, { repoId, topicId, limit: filters.limit });
|
|
815
|
+
for (const t of todos) {
|
|
816
|
+
results.push({
|
|
817
|
+
type: "todo",
|
|
818
|
+
data: {
|
|
819
|
+
id: toEntryId(t.id),
|
|
820
|
+
type: "todo",
|
|
821
|
+
date: t.created_at,
|
|
822
|
+
project: ws.project,
|
|
823
|
+
workspace: ws.workspace,
|
|
824
|
+
topic: t.topic_name,
|
|
825
|
+
tags: t.topic_name ? [t.topic_name] : [],
|
|
826
|
+
content: t.content
|
|
827
|
+
},
|
|
828
|
+
rank: t.rank || 0
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return results.sort((a, b) => a.rank - b.rank);
|
|
833
|
+
}
|
|
834
|
+
getLog(filters) {
|
|
835
|
+
const db2 = getDb();
|
|
836
|
+
const ws = this.getWorkspace();
|
|
837
|
+
const { from, to } = this.resolveDates(filters);
|
|
838
|
+
const topicId = filters.topic ? this.resolveTopicSafe(db2, filters.topic) : void 0;
|
|
839
|
+
const repoId = filters.scope === "global" ? void 0 : this.repoId ?? void 0;
|
|
840
|
+
const entries = [];
|
|
841
|
+
if (!filters.type || filters.type === "all" || filters.type === "notes") {
|
|
842
|
+
const notes = getNotes(db2, { repoId, topicId, from, to });
|
|
843
|
+
for (const n of notes) {
|
|
844
|
+
entries.push({
|
|
845
|
+
type: "note",
|
|
846
|
+
data: {
|
|
847
|
+
id: toEntryId(n.id),
|
|
848
|
+
type: "note",
|
|
849
|
+
date: n.created_at,
|
|
850
|
+
project: ws.project,
|
|
851
|
+
workspace: ws.workspace,
|
|
852
|
+
topic: n.topic_name,
|
|
853
|
+
tags: n.topic_name ? [n.topic_name] : [],
|
|
854
|
+
content: n.content
|
|
855
|
+
},
|
|
856
|
+
timestamp: n.created_at
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (!filters.type || filters.type === "all" || filters.type === "todos") {
|
|
861
|
+
const todos = getCompletedTodos(db2, { repoId, topicId, from, to });
|
|
862
|
+
for (const t of todos) {
|
|
863
|
+
entries.push({
|
|
864
|
+
type: "todo",
|
|
865
|
+
data: {
|
|
866
|
+
id: toEntryId(t.id),
|
|
867
|
+
type: "todo",
|
|
868
|
+
date: t.created_at,
|
|
869
|
+
project: ws.project,
|
|
870
|
+
workspace: ws.workspace,
|
|
871
|
+
topic: t.topic_name,
|
|
872
|
+
tags: t.topic_name ? [t.topic_name] : [],
|
|
873
|
+
content: t.content
|
|
874
|
+
},
|
|
875
|
+
timestamp: t.completed_at || t.created_at
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (repoId) {
|
|
879
|
+
const resolved = getResolvedCodeTodos(db2, repoId, from, to);
|
|
880
|
+
for (const r of resolved) {
|
|
881
|
+
entries.push({
|
|
882
|
+
type: "code_todo_resolved",
|
|
883
|
+
data: {
|
|
884
|
+
id: toEntryId(r.id),
|
|
885
|
+
type: "todo",
|
|
886
|
+
date: r.first_seen_at,
|
|
887
|
+
project: ws.project,
|
|
888
|
+
workspace: ws.workspace,
|
|
889
|
+
topic: r.topic_name,
|
|
890
|
+
tags: [r.topic_name],
|
|
891
|
+
file: r.file,
|
|
892
|
+
line: r.line,
|
|
893
|
+
tag: r.tag,
|
|
894
|
+
content: r.content
|
|
895
|
+
},
|
|
896
|
+
timestamp: r.resolved_at || r.first_seen_at
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
902
|
+
}
|
|
903
|
+
getDueReminders() {
|
|
904
|
+
const db2 = getDb();
|
|
905
|
+
const result = getDueReminders(db2);
|
|
906
|
+
if (!result) return null;
|
|
907
|
+
const ws = this.getWorkspace();
|
|
908
|
+
return {
|
|
909
|
+
today: result.today.map((g) => ({
|
|
910
|
+
repo_name: g.repo_name,
|
|
911
|
+
reminders: g.reminders.map((r) => this.todoToEntry(r, ws))
|
|
912
|
+
})),
|
|
913
|
+
overdue: result.overdue.map((g) => ({
|
|
914
|
+
repo_name: g.repo_name,
|
|
915
|
+
reminders: g.reminders.map((r) => this.todoToEntry(r, ws))
|
|
916
|
+
})),
|
|
917
|
+
recurring: result.recurring.map((g) => ({
|
|
918
|
+
repo_name: g.repo_name,
|
|
919
|
+
reminders: g.reminders.map((r) => this.todoToEntry(r, ws))
|
|
920
|
+
}))
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
getTags(filter) {
|
|
924
|
+
const db2 = getDb();
|
|
925
|
+
const topics = getAllTopics(db2);
|
|
926
|
+
const results = topics.map((t) => ({
|
|
927
|
+
tag: t.name,
|
|
928
|
+
count: 0
|
|
929
|
+
// SQLite mode: count not tracked per-tag
|
|
930
|
+
}));
|
|
931
|
+
if (filter) {
|
|
932
|
+
return results.filter((r) => r.tag === filter);
|
|
933
|
+
}
|
|
934
|
+
return results;
|
|
935
|
+
}
|
|
936
|
+
getTimeline(filters) {
|
|
937
|
+
const logEntries = this.getLog({
|
|
938
|
+
period: filters.period,
|
|
939
|
+
from: filters.from,
|
|
940
|
+
to: filters.to,
|
|
941
|
+
scope: "global"
|
|
942
|
+
});
|
|
943
|
+
return logEntries.map((e) => ({
|
|
944
|
+
type: e.data.type,
|
|
945
|
+
date: e.timestamp,
|
|
946
|
+
workspace: e.data.workspace,
|
|
947
|
+
project: e.data.project,
|
|
948
|
+
summary: (e.data.content || "").slice(0, 100)
|
|
949
|
+
}));
|
|
950
|
+
}
|
|
951
|
+
// ── Topics ──
|
|
952
|
+
getTopics() {
|
|
953
|
+
const db2 = getDb();
|
|
954
|
+
const topics = getAllTopics(db2);
|
|
955
|
+
return topics.map((t) => ({
|
|
956
|
+
id: toEntryId(t.id),
|
|
957
|
+
name: t.name,
|
|
958
|
+
description: t.description,
|
|
959
|
+
is_custom: t.is_custom === 1
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
insertTopic(name, description) {
|
|
963
|
+
const db2 = getDb();
|
|
964
|
+
const topic = insertTopic(db2, name, description);
|
|
965
|
+
return {
|
|
966
|
+
id: toEntryId(topic.id),
|
|
967
|
+
name: topic.name,
|
|
968
|
+
description: topic.description,
|
|
969
|
+
is_custom: topic.is_custom === 1
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
// ── Code TODOs ──
|
|
973
|
+
getCodeTodos(repoPath) {
|
|
974
|
+
return scanCodeTodos(repoPath);
|
|
975
|
+
}
|
|
976
|
+
syncCodeTodos(_repoPath, todos) {
|
|
977
|
+
const db2 = getDb();
|
|
978
|
+
this.ensureRepo();
|
|
979
|
+
if (!this.repoId) return { added: 0, resolved: 0 };
|
|
980
|
+
return syncCodeTodos(db2, this.repoId, todos);
|
|
981
|
+
}
|
|
982
|
+
// ── Private helpers ──
|
|
983
|
+
ensureRepo() {
|
|
984
|
+
if (this.repoId === null) {
|
|
985
|
+
this.autoRegisterRepo();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
todoToEntry(todo, ws) {
|
|
989
|
+
return {
|
|
990
|
+
id: toEntryId(todo.id),
|
|
991
|
+
type: "todo",
|
|
992
|
+
date: todo.created_at,
|
|
993
|
+
project: ws.project,
|
|
994
|
+
workspace: ws.workspace,
|
|
995
|
+
topic: todo.topic_name,
|
|
996
|
+
tags: todo.topic_name ? [todo.topic_name] : [],
|
|
997
|
+
content: todo.content,
|
|
998
|
+
status: todo.status,
|
|
999
|
+
priority: todo.priority,
|
|
1000
|
+
due: todo.remind_at,
|
|
1001
|
+
remind_pattern: todo.remind_pattern,
|
|
1002
|
+
remind_last_done: todo.remind_last_done,
|
|
1003
|
+
completed_at: todo.completed_at
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
resolveTopicSafe(db2, name) {
|
|
1007
|
+
const topic = getTopicByName(db2, name);
|
|
1008
|
+
return topic?.id;
|
|
1009
|
+
}
|
|
1010
|
+
resolveDates(filters) {
|
|
1011
|
+
if (filters.from || filters.to) {
|
|
1012
|
+
return { from: filters.from, to: filters.to };
|
|
1013
|
+
}
|
|
1014
|
+
const now = /* @__PURE__ */ new Date();
|
|
1015
|
+
const today = todayDate();
|
|
1016
|
+
switch (filters.period) {
|
|
1017
|
+
case "today":
|
|
1018
|
+
return { from: `${today}T00:00:00`, to: `${today}T23:59:59` };
|
|
1019
|
+
case "yesterday": {
|
|
1020
|
+
const yesterday = new Date(now.getTime() - 864e5).toISOString().split("T")[0];
|
|
1021
|
+
return { from: `${yesterday}T00:00:00`, to: `${yesterday}T23:59:59` };
|
|
1022
|
+
}
|
|
1023
|
+
case "week": {
|
|
1024
|
+
const weekAgo = new Date(now.getTime() - 7 * 864e5).toISOString().split("T")[0];
|
|
1025
|
+
return { from: `${weekAgo}T00:00:00`, to: `${today}T23:59:59` };
|
|
1026
|
+
}
|
|
1027
|
+
case "month": {
|
|
1028
|
+
const monthAgo = new Date(now.getTime() - 30 * 864e5).toISOString().split("T")[0];
|
|
1029
|
+
return { from: `${monthAgo}T00:00:00`, to: `${today}T23:59:59` };
|
|
1030
|
+
}
|
|
1031
|
+
default:
|
|
1032
|
+
return { from: `${today}T00:00:00`, to: `${today}T23:59:59` };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// src/storage/obsidian/index.ts
|
|
1038
|
+
import { existsSync as existsSync4, unlinkSync } from "fs";
|
|
1039
|
+
import { basename as basename5, join as join5 } from "path";
|
|
1040
|
+
|
|
1041
|
+
// src/storage/obsidian/slug.ts
|
|
1042
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1043
|
+
import { join as join2 } from "path";
|
|
1044
|
+
var ACCENT_MAP = {
|
|
1045
|
+
"\xE1": "a",
|
|
1046
|
+
"\xE0": "a",
|
|
1047
|
+
"\xE4": "a",
|
|
1048
|
+
"\xE2": "a",
|
|
1049
|
+
"\xE3": "a",
|
|
1050
|
+
"\xE9": "e",
|
|
1051
|
+
"\xE8": "e",
|
|
1052
|
+
"\xEB": "e",
|
|
1053
|
+
"\xEA": "e",
|
|
1054
|
+
"\xED": "i",
|
|
1055
|
+
"\xEC": "i",
|
|
1056
|
+
"\xEF": "i",
|
|
1057
|
+
"\xEE": "i",
|
|
1058
|
+
"\xF3": "o",
|
|
1059
|
+
"\xF2": "o",
|
|
1060
|
+
"\xF6": "o",
|
|
1061
|
+
"\xF4": "o",
|
|
1062
|
+
"\xF5": "o",
|
|
1063
|
+
"\xFA": "u",
|
|
1064
|
+
"\xF9": "u",
|
|
1065
|
+
"\xFC": "u",
|
|
1066
|
+
"\xFB": "u",
|
|
1067
|
+
"\xF1": "n",
|
|
1068
|
+
"\xE7": "c"
|
|
1069
|
+
};
|
|
1070
|
+
function generateSlug(text, maxLen = 50) {
|
|
1071
|
+
let slug = text.toLowerCase();
|
|
1072
|
+
slug = slug.replace(/[áàäâãéèëêíìïîóòöôõúùüûñç]/g, (ch) => ACCENT_MAP[ch] || ch);
|
|
1073
|
+
slug = slug.replace(/[^a-z0-9]+/g, "-");
|
|
1074
|
+
slug = slug.replace(/-{2,}/g, "-");
|
|
1075
|
+
slug = slug.replace(/^-|-$/g, "");
|
|
1076
|
+
if (slug.length > maxLen) {
|
|
1077
|
+
slug = slug.slice(0, maxLen).replace(/-$/, "");
|
|
1078
|
+
}
|
|
1079
|
+
return slug || "entry";
|
|
1080
|
+
}
|
|
1081
|
+
function resolveFilename(dir, date, slug) {
|
|
1082
|
+
const base = `${date}-${slug}.md`;
|
|
1083
|
+
const fullPath = join2(dir, base);
|
|
1084
|
+
if (!existsSync2(fullPath)) return base;
|
|
1085
|
+
let counter = 2;
|
|
1086
|
+
while (true) {
|
|
1087
|
+
const candidate = `${date}-${slug}-${counter}.md`;
|
|
1088
|
+
if (!existsSync2(join2(dir, candidate))) return candidate;
|
|
1089
|
+
counter++;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/storage/obsidian/workspace.ts
|
|
1094
|
+
import { basename as basename3 } from "path";
|
|
1095
|
+
function detectWorkspace(repoPath) {
|
|
1096
|
+
if (!repoPath) {
|
|
1097
|
+
return {
|
|
1098
|
+
workspace: process.env.LOGBOOK_WORKSPACE || "default",
|
|
1099
|
+
project: "global"
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const normalized = repoPath.replace(/\\/g, "/");
|
|
1103
|
+
const project = basename3(normalized);
|
|
1104
|
+
const lowerPath = normalized.toLowerCase();
|
|
1105
|
+
const projectsIdx = lowerPath.indexOf("/projects/");
|
|
1106
|
+
if (projectsIdx !== -1) {
|
|
1107
|
+
const before = normalized.slice(0, projectsIdx);
|
|
1108
|
+
const workspace = basename3(before);
|
|
1109
|
+
if (workspace) {
|
|
1110
|
+
return { workspace, project };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1114
|
+
if (parts.length >= 2) {
|
|
1115
|
+
return { workspace: parts[parts.length - 2], project };
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
workspace: process.env.LOGBOOK_WORKSPACE || "default",
|
|
1119
|
+
project
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/storage/obsidian/wikilinks.ts
|
|
1124
|
+
import { readdirSync } from "fs";
|
|
1125
|
+
import { join as join3 } from "path";
|
|
1126
|
+
function getKnownProjects(baseDir) {
|
|
1127
|
+
const projects = [];
|
|
1128
|
+
try {
|
|
1129
|
+
const workspaces = readdirSync(baseDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
1130
|
+
for (const ws of workspaces) {
|
|
1131
|
+
try {
|
|
1132
|
+
const projectDirs = readdirSync(join3(baseDir, ws.name), { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
1133
|
+
for (const proj of projectDirs) {
|
|
1134
|
+
projects.push(proj.name);
|
|
1135
|
+
}
|
|
1136
|
+
} catch {
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
}
|
|
1141
|
+
return [...new Set(projects)];
|
|
1142
|
+
}
|
|
1143
|
+
function applyWikilinks(content, knownProjects) {
|
|
1144
|
+
if (knownProjects.length === 0) return content;
|
|
1145
|
+
let result = content;
|
|
1146
|
+
for (const project of knownProjects) {
|
|
1147
|
+
if (project.length < 3) continue;
|
|
1148
|
+
const escaped = project.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1149
|
+
const regex = new RegExp(`(?<!\\[\\[)\\b(${escaped})\\b(?!\\]\\])`, "g");
|
|
1150
|
+
result = result.replace(regex, "[[$1]]");
|
|
1151
|
+
}
|
|
1152
|
+
return result;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/storage/obsidian/files.ts
|
|
1156
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync, copyFileSync, readdirSync as readdirSync2 } from "fs";
|
|
1157
|
+
import { join as join4, basename as basename4 } from "path";
|
|
1158
|
+
|
|
1159
|
+
// src/storage/obsidian/frontmatter.ts
|
|
1160
|
+
function parseFrontmatter(raw) {
|
|
1161
|
+
const lines = raw.split("\n");
|
|
1162
|
+
const firstIdx = lines.findIndex((l) => l.trim() === "---");
|
|
1163
|
+
if (firstIdx === -1) {
|
|
1164
|
+
return { frontmatter: {}, body: raw };
|
|
1165
|
+
}
|
|
1166
|
+
const secondIdx = lines.findIndex((l, i) => i > firstIdx && l.trim() === "---");
|
|
1167
|
+
if (secondIdx === -1) {
|
|
1168
|
+
return { frontmatter: {}, body: raw };
|
|
1169
|
+
}
|
|
1170
|
+
const fmLines = lines.slice(firstIdx + 1, secondIdx);
|
|
1171
|
+
const frontmatter = {};
|
|
1172
|
+
for (const line of fmLines) {
|
|
1173
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
1174
|
+
const colonIdx = line.indexOf(":");
|
|
1175
|
+
if (colonIdx === -1) continue;
|
|
1176
|
+
const key = line.slice(0, colonIdx).trim();
|
|
1177
|
+
const rawValue = line.slice(colonIdx + 1).trim();
|
|
1178
|
+
if (!key) continue;
|
|
1179
|
+
frontmatter[key] = parseValue(rawValue);
|
|
1180
|
+
}
|
|
1181
|
+
const body = lines.slice(secondIdx + 1).join("\n").replace(/^\n/, "");
|
|
1182
|
+
return { frontmatter, body };
|
|
1183
|
+
}
|
|
1184
|
+
function serializeFrontmatter(data) {
|
|
1185
|
+
const lines = ["---"];
|
|
1186
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1187
|
+
if (value === null || value === void 0) continue;
|
|
1188
|
+
lines.push(`${key}: ${serializeValue(value)}`);
|
|
1189
|
+
}
|
|
1190
|
+
lines.push("---");
|
|
1191
|
+
return lines.join("\n") + "\n";
|
|
1192
|
+
}
|
|
1193
|
+
function parseValue(raw) {
|
|
1194
|
+
if (raw === "" || raw === "null" || raw === "~") return null;
|
|
1195
|
+
if (raw === "true") return true;
|
|
1196
|
+
if (raw === "false") return false;
|
|
1197
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
1198
|
+
const inner = raw.slice(1, -1).trim();
|
|
1199
|
+
if (inner === "") return [];
|
|
1200
|
+
return inner.split(",").map((item) => item.trim().replace(/^["']|["']$/g, ""));
|
|
1201
|
+
}
|
|
1202
|
+
if (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'")) {
|
|
1203
|
+
return raw.slice(1, -1);
|
|
1204
|
+
}
|
|
1205
|
+
if (/^\d+(\.\d+)?$/.test(raw)) {
|
|
1206
|
+
return Number(raw);
|
|
1207
|
+
}
|
|
1208
|
+
return raw;
|
|
1209
|
+
}
|
|
1210
|
+
function serializeValue(value) {
|
|
1211
|
+
if (typeof value === "boolean") return String(value);
|
|
1212
|
+
if (typeof value === "number") return String(value);
|
|
1213
|
+
if (Array.isArray(value)) {
|
|
1214
|
+
return `[${value.join(", ")}]`;
|
|
1215
|
+
}
|
|
1216
|
+
if (typeof value === "string" && /[:#\[\]{},>|&*!%@`]/.test(value)) {
|
|
1217
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
1218
|
+
}
|
|
1219
|
+
return String(value);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/storage/obsidian/files.ts
|
|
1223
|
+
function ensureDir(dir) {
|
|
1224
|
+
if (!existsSync3(dir)) {
|
|
1225
|
+
mkdirSync2(dir, { recursive: true });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
function globMarkdown(dir) {
|
|
1229
|
+
const results = [];
|
|
1230
|
+
if (!existsSync3(dir)) return results;
|
|
1231
|
+
function walk(currentDir) {
|
|
1232
|
+
const entries = readdirSync2(currentDir, { withFileTypes: true });
|
|
1233
|
+
for (const entry of entries) {
|
|
1234
|
+
const fullPath = join4(currentDir, entry.name);
|
|
1235
|
+
if (entry.isDirectory()) {
|
|
1236
|
+
walk(fullPath);
|
|
1237
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
1238
|
+
results.push(fullPath);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
walk(dir);
|
|
1243
|
+
return results;
|
|
1244
|
+
}
|
|
1245
|
+
function readEntry(path) {
|
|
1246
|
+
const raw = readFileSync(path, "utf-8");
|
|
1247
|
+
return parseFrontmatter(raw);
|
|
1248
|
+
}
|
|
1249
|
+
function writeEntry(path, frontmatter, body) {
|
|
1250
|
+
const dir = join4(path, "..");
|
|
1251
|
+
ensureDir(dir);
|
|
1252
|
+
const content = serializeFrontmatter(frontmatter) + "\n" + body;
|
|
1253
|
+
writeFileSync(path, content, "utf-8");
|
|
1254
|
+
}
|
|
1255
|
+
function copyAttachment(src, destDir) {
|
|
1256
|
+
ensureDir(destDir);
|
|
1257
|
+
const filename = basename4(src);
|
|
1258
|
+
let destPath = join4(destDir, filename);
|
|
1259
|
+
if (existsSync3(destPath)) {
|
|
1260
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")) : "";
|
|
1261
|
+
const name = filename.includes(".") ? filename.slice(0, filename.lastIndexOf(".")) : filename;
|
|
1262
|
+
let counter = 2;
|
|
1263
|
+
while (existsSync3(destPath)) {
|
|
1264
|
+
destPath = join4(destDir, `${name}-${counter}${ext}`);
|
|
1265
|
+
counter++;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
copyFileSync(src, destPath);
|
|
1269
|
+
return basename4(destPath);
|
|
1270
|
+
}
|
|
1271
|
+
function extractIdFromFilename(filename) {
|
|
1272
|
+
return filename.replace(/\.md$/, "");
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/storage/obsidian/formatting.ts
|
|
1276
|
+
var PRIORITY_EMOJI = {
|
|
1277
|
+
urgent: "\u23EB",
|
|
1278
|
+
// ⏫
|
|
1279
|
+
high: "\u{1F53C}",
|
|
1280
|
+
// 🔼
|
|
1281
|
+
normal: "",
|
|
1282
|
+
low: "\u{1F53D}"
|
|
1283
|
+
// 🔽
|
|
1284
|
+
};
|
|
1285
|
+
function formatTodoCheckbox(content, priority, due, done, completedAt) {
|
|
1286
|
+
const checkbox = done ? "- [x]" : "- [ ]";
|
|
1287
|
+
const parts = [checkbox, content];
|
|
1288
|
+
const emoji = PRIORITY_EMOJI[priority];
|
|
1289
|
+
if (emoji) parts.push(emoji);
|
|
1290
|
+
if (due && !done) {
|
|
1291
|
+
parts.push(`\u{1F4C5} ${due}`);
|
|
1292
|
+
}
|
|
1293
|
+
if (done && completedAt) {
|
|
1294
|
+
parts.push(`\u2705 ${completedAt.split("T")[0]}`);
|
|
1295
|
+
}
|
|
1296
|
+
return parts.join(" ");
|
|
1297
|
+
}
|
|
1298
|
+
function formatStandup(yesterday, today, blockers) {
|
|
1299
|
+
return [
|
|
1300
|
+
"## Yesterday",
|
|
1301
|
+
yesterday,
|
|
1302
|
+
"",
|
|
1303
|
+
"## Today",
|
|
1304
|
+
today,
|
|
1305
|
+
"",
|
|
1306
|
+
"## Blockers",
|
|
1307
|
+
blockers || "Ninguno"
|
|
1308
|
+
].join("\n");
|
|
1309
|
+
}
|
|
1310
|
+
function formatDecision(title, context, options, decision, consequences) {
|
|
1311
|
+
const optionsList = options.map((o, i) => `${i + 1}. ${o}`).join("\n");
|
|
1312
|
+
return [
|
|
1313
|
+
`# ${title}`,
|
|
1314
|
+
"",
|
|
1315
|
+
"## Contexto",
|
|
1316
|
+
context,
|
|
1317
|
+
"",
|
|
1318
|
+
"## Opciones",
|
|
1319
|
+
optionsList,
|
|
1320
|
+
"",
|
|
1321
|
+
"## Decision",
|
|
1322
|
+
decision,
|
|
1323
|
+
"",
|
|
1324
|
+
"## Consecuencias",
|
|
1325
|
+
consequences
|
|
1326
|
+
].join("\n");
|
|
1327
|
+
}
|
|
1328
|
+
function formatDebug(title, error, cause, fix, attachment) {
|
|
1329
|
+
const parts = [
|
|
1330
|
+
`> [!bug] ${title}`,
|
|
1331
|
+
">",
|
|
1332
|
+
"> **Error**",
|
|
1333
|
+
`> ${error}`,
|
|
1334
|
+
">",
|
|
1335
|
+
"> **Causa**",
|
|
1336
|
+
`> ${cause}`,
|
|
1337
|
+
">",
|
|
1338
|
+
"> **Fix**",
|
|
1339
|
+
`> ${fix}`
|
|
1340
|
+
];
|
|
1341
|
+
if (attachment) {
|
|
1342
|
+
parts.push(">", `> ![[${attachment}]]`);
|
|
1343
|
+
}
|
|
1344
|
+
return parts.join("\n");
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/storage/obsidian/index.ts
|
|
1348
|
+
var TYPE_FOLDERS = {
|
|
1349
|
+
note: "notes",
|
|
1350
|
+
todo: "todos",
|
|
1351
|
+
decision: "decisions",
|
|
1352
|
+
debug: "debug",
|
|
1353
|
+
standup: "standups"
|
|
1354
|
+
};
|
|
1355
|
+
function todayDate2() {
|
|
1356
|
+
return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1357
|
+
}
|
|
1358
|
+
function nowISO() {
|
|
1359
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1360
|
+
}
|
|
1361
|
+
function matchesPattern2(pattern, date) {
|
|
1362
|
+
const dayOfWeek = date.getDay();
|
|
1363
|
+
const dayOfMonth = date.getDate();
|
|
1364
|
+
if (pattern === "daily") return true;
|
|
1365
|
+
if (pattern === "weekdays") return dayOfWeek >= 1 && dayOfWeek <= 5;
|
|
1366
|
+
if (pattern.startsWith("weekly:")) {
|
|
1367
|
+
const days = pattern.slice(7).split(",").map(Number);
|
|
1368
|
+
return days.some((d) => d % 7 === dayOfWeek);
|
|
1369
|
+
}
|
|
1370
|
+
if (pattern.startsWith("monthly:")) {
|
|
1371
|
+
const days = pattern.slice(8).split(",").map(Number);
|
|
1372
|
+
return days.includes(dayOfMonth);
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
var ObsidianStorage = class {
|
|
1377
|
+
baseDir;
|
|
1378
|
+
repoPath = null;
|
|
1379
|
+
wsInfo = null;
|
|
1380
|
+
constructor(baseDir) {
|
|
1381
|
+
this.baseDir = baseDir;
|
|
1382
|
+
ensureDir(baseDir);
|
|
1383
|
+
}
|
|
1384
|
+
// ── Repos ──
|
|
1385
|
+
autoRegisterRepo() {
|
|
1386
|
+
this.repoPath = detectRepoPath();
|
|
1387
|
+
if (!this.repoPath) return null;
|
|
1388
|
+
this.wsInfo = detectWorkspace(this.repoPath);
|
|
1389
|
+
const name = basename5(this.repoPath);
|
|
1390
|
+
return {
|
|
1391
|
+
id: name,
|
|
1392
|
+
name,
|
|
1393
|
+
path: this.repoPath
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
getWorkspace() {
|
|
1397
|
+
if (!this.wsInfo) {
|
|
1398
|
+
if (!this.repoPath) this.repoPath = detectRepoPath();
|
|
1399
|
+
this.wsInfo = detectWorkspace(this.repoPath);
|
|
1400
|
+
}
|
|
1401
|
+
return this.wsInfo;
|
|
1402
|
+
}
|
|
1403
|
+
// ── Notes ──
|
|
1404
|
+
insertNote(content, topic) {
|
|
1405
|
+
const ws = this.getWorkspace();
|
|
1406
|
+
const date = todayDate2();
|
|
1407
|
+
const slug = generateSlug(content.slice(0, 80));
|
|
1408
|
+
const dir = this.typeDir(ws, "note");
|
|
1409
|
+
const filename = resolveFilename(dir, date, slug);
|
|
1410
|
+
const id = extractIdFromFilename(filename);
|
|
1411
|
+
const knownProjects = getKnownProjects(this.baseDir);
|
|
1412
|
+
const body = applyWikilinks(content, knownProjects);
|
|
1413
|
+
const fm = {
|
|
1414
|
+
type: "note",
|
|
1415
|
+
date,
|
|
1416
|
+
project: ws.project,
|
|
1417
|
+
workspace: ws.workspace
|
|
1418
|
+
};
|
|
1419
|
+
if (topic) fm.topic = topic;
|
|
1420
|
+
if (topic) fm.tags = [topic];
|
|
1421
|
+
writeEntry(join5(dir, filename), fm, body);
|
|
1422
|
+
return {
|
|
1423
|
+
id,
|
|
1424
|
+
type: "note",
|
|
1425
|
+
date,
|
|
1426
|
+
project: ws.project,
|
|
1427
|
+
workspace: ws.workspace,
|
|
1428
|
+
topic: topic || null,
|
|
1429
|
+
tags: topic ? [topic] : [],
|
|
1430
|
+
content: body
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
getNotes(filters) {
|
|
1434
|
+
const ws = this.getWorkspace();
|
|
1435
|
+
const dir = this.typeDir(ws, "note");
|
|
1436
|
+
const files = globMarkdown(dir);
|
|
1437
|
+
return this.filterAndMap(files, filters, (fm, body, id) => ({
|
|
1438
|
+
id,
|
|
1439
|
+
type: "note",
|
|
1440
|
+
date: String(fm.date || ""),
|
|
1441
|
+
project: String(fm.project || ws.project),
|
|
1442
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1443
|
+
topic: fm.topic,
|
|
1444
|
+
tags: fm.tags || [],
|
|
1445
|
+
content: body
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
// ── TODOs ──
|
|
1449
|
+
insertTodo(content, opts) {
|
|
1450
|
+
const ws = this.getWorkspace();
|
|
1451
|
+
const date = todayDate2();
|
|
1452
|
+
const priority = opts?.priority || "normal";
|
|
1453
|
+
const slug = generateSlug(content.slice(0, 80));
|
|
1454
|
+
const dir = this.typeDir(ws, "todo");
|
|
1455
|
+
const filename = resolveFilename(dir, date, slug);
|
|
1456
|
+
const id = extractIdFromFilename(filename);
|
|
1457
|
+
const fm = {
|
|
1458
|
+
type: "todo",
|
|
1459
|
+
date,
|
|
1460
|
+
project: ws.project,
|
|
1461
|
+
workspace: ws.workspace,
|
|
1462
|
+
status: "pending",
|
|
1463
|
+
priority
|
|
1464
|
+
};
|
|
1465
|
+
if (opts?.topic) {
|
|
1466
|
+
fm.topic = opts.topic;
|
|
1467
|
+
fm.tags = [opts.topic];
|
|
1468
|
+
}
|
|
1469
|
+
if (opts?.remind_at) fm.due = opts.remind_at;
|
|
1470
|
+
if (opts?.remind_pattern) fm.remind_pattern = opts.remind_pattern;
|
|
1471
|
+
const body = formatTodoCheckbox(content, priority, opts?.remind_at);
|
|
1472
|
+
writeEntry(join5(dir, filename), fm, body);
|
|
1473
|
+
return {
|
|
1474
|
+
id,
|
|
1475
|
+
type: "todo",
|
|
1476
|
+
date,
|
|
1477
|
+
project: ws.project,
|
|
1478
|
+
workspace: ws.workspace,
|
|
1479
|
+
topic: opts?.topic || null,
|
|
1480
|
+
tags: opts?.topic ? [opts.topic] : [],
|
|
1481
|
+
content,
|
|
1482
|
+
status: "pending",
|
|
1483
|
+
priority,
|
|
1484
|
+
due: opts?.remind_at || null,
|
|
1485
|
+
remind_pattern: opts?.remind_pattern || null,
|
|
1486
|
+
remind_last_done: null,
|
|
1487
|
+
completed_at: null
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
getTodos(filters) {
|
|
1491
|
+
const ws = this.getWorkspace();
|
|
1492
|
+
const dir = this.typeDir(ws, "todo");
|
|
1493
|
+
const files = globMarkdown(dir);
|
|
1494
|
+
return this.filterAndMap(files, filters, (fm, body, id) => ({
|
|
1495
|
+
id,
|
|
1496
|
+
type: "todo",
|
|
1497
|
+
date: String(fm.date || ""),
|
|
1498
|
+
project: String(fm.project || ws.project),
|
|
1499
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1500
|
+
topic: fm.topic || null,
|
|
1501
|
+
tags: fm.tags || [],
|
|
1502
|
+
content: this.extractTodoContent(body),
|
|
1503
|
+
status: fm.status || "pending",
|
|
1504
|
+
priority: fm.priority || "normal",
|
|
1505
|
+
due: fm.due || null,
|
|
1506
|
+
remind_pattern: fm.remind_pattern || null,
|
|
1507
|
+
remind_last_done: fm.remind_last_done || null,
|
|
1508
|
+
completed_at: fm.completed_at || null
|
|
1509
|
+
})).filter((t) => {
|
|
1510
|
+
if (filters.status && filters.status !== "all" && t.status !== filters.status) return false;
|
|
1511
|
+
if (filters.priority && t.priority !== filters.priority) return false;
|
|
1512
|
+
return true;
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
updateTodoStatus(ids, status) {
|
|
1516
|
+
const ws = this.getWorkspace();
|
|
1517
|
+
const dir = this.typeDir(ws, "todo");
|
|
1518
|
+
const results = [];
|
|
1519
|
+
for (const id of ids) {
|
|
1520
|
+
const path = this.findFileById(dir, id);
|
|
1521
|
+
if (!path) continue;
|
|
1522
|
+
const { frontmatter: fm, body } = readEntry(path);
|
|
1523
|
+
fm.status = status;
|
|
1524
|
+
if (status === "done") {
|
|
1525
|
+
fm.completed_at = nowISO();
|
|
1526
|
+
} else {
|
|
1527
|
+
fm.completed_at = null;
|
|
1528
|
+
}
|
|
1529
|
+
const content = this.extractTodoContent(body);
|
|
1530
|
+
const newBody = formatTodoCheckbox(
|
|
1531
|
+
content,
|
|
1532
|
+
fm.priority || "normal",
|
|
1533
|
+
fm.due,
|
|
1534
|
+
status === "done",
|
|
1535
|
+
fm.completed_at
|
|
1536
|
+
);
|
|
1537
|
+
writeEntry(path, fm, newBody);
|
|
1538
|
+
results.push({
|
|
1539
|
+
id,
|
|
1540
|
+
type: "todo",
|
|
1541
|
+
date: String(fm.date || ""),
|
|
1542
|
+
project: String(fm.project || ws.project),
|
|
1543
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1544
|
+
topic: fm.topic || null,
|
|
1545
|
+
tags: fm.tags || [],
|
|
1546
|
+
content,
|
|
1547
|
+
status,
|
|
1548
|
+
priority: fm.priority || "normal",
|
|
1549
|
+
due: fm.due || null,
|
|
1550
|
+
remind_pattern: fm.remind_pattern || null,
|
|
1551
|
+
remind_last_done: fm.remind_last_done || null,
|
|
1552
|
+
completed_at: fm.completed_at || null
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
return results;
|
|
1556
|
+
}
|
|
1557
|
+
updateTodo(id, fields) {
|
|
1558
|
+
const ws = this.getWorkspace();
|
|
1559
|
+
const dir = this.typeDir(ws, "todo");
|
|
1560
|
+
const path = this.findFileById(dir, id);
|
|
1561
|
+
if (!path) return null;
|
|
1562
|
+
const { frontmatter: fm } = readEntry(path);
|
|
1563
|
+
if (fields.content !== void 0) {
|
|
1564
|
+
}
|
|
1565
|
+
if (fields.topic !== void 0) {
|
|
1566
|
+
fm.topic = fields.topic;
|
|
1567
|
+
fm.tags = [fields.topic];
|
|
1568
|
+
}
|
|
1569
|
+
if (fields.priority !== void 0) fm.priority = fields.priority;
|
|
1570
|
+
const content = fields.content ?? this.extractTodoContent(readEntry(path).body);
|
|
1571
|
+
const newBody = formatTodoCheckbox(
|
|
1572
|
+
content,
|
|
1573
|
+
fm.priority || "normal",
|
|
1574
|
+
fm.due,
|
|
1575
|
+
fm.status === "done",
|
|
1576
|
+
fm.completed_at
|
|
1577
|
+
);
|
|
1578
|
+
writeEntry(path, fm, newBody);
|
|
1579
|
+
return {
|
|
1580
|
+
id,
|
|
1581
|
+
type: "todo",
|
|
1582
|
+
date: String(fm.date || ""),
|
|
1583
|
+
project: String(fm.project || ws.project),
|
|
1584
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1585
|
+
topic: fm.topic || null,
|
|
1586
|
+
tags: fm.tags || [],
|
|
1587
|
+
content,
|
|
1588
|
+
status: fm.status || "pending",
|
|
1589
|
+
priority: fm.priority || "normal",
|
|
1590
|
+
due: fm.due || null,
|
|
1591
|
+
remind_pattern: fm.remind_pattern || null,
|
|
1592
|
+
remind_last_done: fm.remind_last_done || null,
|
|
1593
|
+
completed_at: fm.completed_at || null
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
deleteTodos(ids) {
|
|
1597
|
+
const ws = this.getWorkspace();
|
|
1598
|
+
const dir = this.typeDir(ws, "todo");
|
|
1599
|
+
const deleted = [];
|
|
1600
|
+
for (const id of ids) {
|
|
1601
|
+
const path = this.findFileById(dir, id);
|
|
1602
|
+
if (path && existsSync4(path)) {
|
|
1603
|
+
unlinkSync(path);
|
|
1604
|
+
deleted.push(id);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return deleted;
|
|
1608
|
+
}
|
|
1609
|
+
ackRecurringReminder(id) {
|
|
1610
|
+
const ws = this.getWorkspace();
|
|
1611
|
+
const dir = this.typeDir(ws, "todo");
|
|
1612
|
+
const path = this.findFileById(dir, id);
|
|
1613
|
+
if (!path) return;
|
|
1614
|
+
const { frontmatter: fm, body } = readEntry(path);
|
|
1615
|
+
fm.remind_last_done = todayDate2();
|
|
1616
|
+
writeEntry(path, fm, body);
|
|
1617
|
+
}
|
|
1618
|
+
// ── Specialized entries ──
|
|
1619
|
+
insertStandup(yesterday, today, blockers, topic) {
|
|
1620
|
+
const ws = this.getWorkspace();
|
|
1621
|
+
const date = todayDate2();
|
|
1622
|
+
const slug = `standup-${date}`;
|
|
1623
|
+
const dir = this.typeDir(ws, "standup");
|
|
1624
|
+
const filename = resolveFilename(dir, date, slug);
|
|
1625
|
+
const id = extractIdFromFilename(filename);
|
|
1626
|
+
const fm = {
|
|
1627
|
+
type: "standup",
|
|
1628
|
+
date,
|
|
1629
|
+
project: ws.project,
|
|
1630
|
+
workspace: ws.workspace
|
|
1631
|
+
};
|
|
1632
|
+
if (topic) {
|
|
1633
|
+
fm.topic = topic;
|
|
1634
|
+
fm.tags = [topic];
|
|
1635
|
+
}
|
|
1636
|
+
const knownProjects = getKnownProjects(this.baseDir);
|
|
1637
|
+
const body = applyWikilinks(formatStandup(yesterday, today, blockers), knownProjects);
|
|
1638
|
+
writeEntry(join5(dir, filename), fm, body);
|
|
1639
|
+
return {
|
|
1640
|
+
id,
|
|
1641
|
+
type: "standup",
|
|
1642
|
+
date,
|
|
1643
|
+
project: ws.project,
|
|
1644
|
+
workspace: ws.workspace,
|
|
1645
|
+
topic: topic || null,
|
|
1646
|
+
tags: topic ? [topic] : [],
|
|
1647
|
+
yesterday,
|
|
1648
|
+
today,
|
|
1649
|
+
blockers: blockers || "Ninguno"
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
insertDecision(title, context, options, decision, consequences, topic) {
|
|
1653
|
+
const ws = this.getWorkspace();
|
|
1654
|
+
const date = todayDate2();
|
|
1655
|
+
const slug = generateSlug(title);
|
|
1656
|
+
const dir = this.typeDir(ws, "decision");
|
|
1657
|
+
const filename = resolveFilename(dir, date, slug);
|
|
1658
|
+
const id = extractIdFromFilename(filename);
|
|
1659
|
+
const fm = {
|
|
1660
|
+
type: "decision",
|
|
1661
|
+
date,
|
|
1662
|
+
project: ws.project,
|
|
1663
|
+
workspace: ws.workspace
|
|
1664
|
+
};
|
|
1665
|
+
if (topic) {
|
|
1666
|
+
fm.topic = topic || "decision";
|
|
1667
|
+
fm.tags = [topic || "decision"];
|
|
1668
|
+
}
|
|
1669
|
+
const knownProjects = getKnownProjects(this.baseDir);
|
|
1670
|
+
const body = applyWikilinks(formatDecision(title, context, options, decision, consequences), knownProjects);
|
|
1671
|
+
writeEntry(join5(dir, filename), fm, body);
|
|
1672
|
+
return {
|
|
1673
|
+
id,
|
|
1674
|
+
type: "decision",
|
|
1675
|
+
date,
|
|
1676
|
+
project: ws.project,
|
|
1677
|
+
workspace: ws.workspace,
|
|
1678
|
+
topic: topic || "decision",
|
|
1679
|
+
tags: [topic || "decision"],
|
|
1680
|
+
title,
|
|
1681
|
+
context,
|
|
1682
|
+
options,
|
|
1683
|
+
decision,
|
|
1684
|
+
consequences
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
insertDebug(title, error, cause, fix, file, topic) {
|
|
1688
|
+
const ws = this.getWorkspace();
|
|
1689
|
+
const date = todayDate2();
|
|
1690
|
+
const slug = generateSlug(title);
|
|
1691
|
+
const dir = this.typeDir(ws, "debug");
|
|
1692
|
+
const filename = resolveFilename(dir, date, slug);
|
|
1693
|
+
const id = extractIdFromFilename(filename);
|
|
1694
|
+
let attachmentName = null;
|
|
1695
|
+
if (file) {
|
|
1696
|
+
if (!existsSync4(file)) {
|
|
1697
|
+
throw new Error(`Archivo no encontrado: ${file}`);
|
|
1698
|
+
}
|
|
1699
|
+
const attachDir = join5(this.projectDir(ws), "attachments");
|
|
1700
|
+
attachmentName = copyAttachment(file, attachDir);
|
|
1701
|
+
}
|
|
1702
|
+
const fm = {
|
|
1703
|
+
type: "debug",
|
|
1704
|
+
date,
|
|
1705
|
+
project: ws.project,
|
|
1706
|
+
workspace: ws.workspace
|
|
1707
|
+
};
|
|
1708
|
+
if (topic) {
|
|
1709
|
+
fm.topic = topic || "fix";
|
|
1710
|
+
fm.tags = [topic || "fix"];
|
|
1711
|
+
}
|
|
1712
|
+
const knownProjects = getKnownProjects(this.baseDir);
|
|
1713
|
+
const body = applyWikilinks(formatDebug(title, error, cause, fix, attachmentName), knownProjects);
|
|
1714
|
+
writeEntry(join5(dir, filename), fm, body);
|
|
1715
|
+
return {
|
|
1716
|
+
id,
|
|
1717
|
+
type: "debug",
|
|
1718
|
+
date,
|
|
1719
|
+
project: ws.project,
|
|
1720
|
+
workspace: ws.workspace,
|
|
1721
|
+
topic: topic || "fix",
|
|
1722
|
+
tags: [topic || "fix"],
|
|
1723
|
+
title,
|
|
1724
|
+
error,
|
|
1725
|
+
cause,
|
|
1726
|
+
fix,
|
|
1727
|
+
attachment: attachmentName
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
// ── Search & queries ──
|
|
1731
|
+
search(query, filters) {
|
|
1732
|
+
const ws = this.getWorkspace();
|
|
1733
|
+
const searchDir = filters.scope === "global" ? this.baseDir : this.projectDir(ws);
|
|
1734
|
+
const files = globMarkdown(searchDir);
|
|
1735
|
+
const queryLower = query.toLowerCase();
|
|
1736
|
+
const results = [];
|
|
1737
|
+
for (const file of files) {
|
|
1738
|
+
const { frontmatter: fm, body } = readEntry(file);
|
|
1739
|
+
if (filters.type && filters.type !== "all") {
|
|
1740
|
+
const fmType = fm.type;
|
|
1741
|
+
if (filters.type === "notes" && fmType !== "note") continue;
|
|
1742
|
+
if (filters.type === "todos" && fmType !== "todo") continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (filters.topic && fm.topic !== filters.topic) continue;
|
|
1745
|
+
const searchable = `${body} ${Object.values(fm).join(" ")}`.toLowerCase();
|
|
1746
|
+
if (!searchable.includes(queryLower)) continue;
|
|
1747
|
+
results.push({
|
|
1748
|
+
type: fm.type || "note",
|
|
1749
|
+
data: {
|
|
1750
|
+
id: extractIdFromFilename(basename5(file)),
|
|
1751
|
+
type: fm.type || "note",
|
|
1752
|
+
date: String(fm.date || ""),
|
|
1753
|
+
project: String(fm.project || ws.project),
|
|
1754
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1755
|
+
topic: fm.topic || null,
|
|
1756
|
+
tags: fm.tags || [],
|
|
1757
|
+
content: body
|
|
1758
|
+
},
|
|
1759
|
+
rank: 0
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
return results.slice(0, filters.limit || 20);
|
|
1763
|
+
}
|
|
1764
|
+
getLog(filters) {
|
|
1765
|
+
const ws = this.getWorkspace();
|
|
1766
|
+
const { from, to } = this.resolveDates(filters);
|
|
1767
|
+
const searchDir = filters.scope === "global" ? this.baseDir : this.projectDir(ws);
|
|
1768
|
+
const files = globMarkdown(searchDir);
|
|
1769
|
+
const entries = [];
|
|
1770
|
+
for (const file of files) {
|
|
1771
|
+
const { frontmatter: fm, body } = readEntry(file);
|
|
1772
|
+
const date = String(fm.date || "");
|
|
1773
|
+
if (!date) continue;
|
|
1774
|
+
if (from && date < from.split("T")[0]) continue;
|
|
1775
|
+
if (to && date > to.split("T")[0]) continue;
|
|
1776
|
+
const fmType = fm.type || "note";
|
|
1777
|
+
if (filters.type && filters.type !== "all") {
|
|
1778
|
+
if (filters.type === "notes" && fmType !== "note" && fmType !== "decision" && fmType !== "debug" && fmType !== "standup") continue;
|
|
1779
|
+
if (filters.type === "todos" && fmType !== "todo") continue;
|
|
1780
|
+
}
|
|
1781
|
+
if (filters.topic && fm.topic !== filters.topic) continue;
|
|
1782
|
+
entries.push({
|
|
1783
|
+
type: fmType,
|
|
1784
|
+
data: {
|
|
1785
|
+
id: extractIdFromFilename(basename5(file)),
|
|
1786
|
+
type: fmType,
|
|
1787
|
+
date,
|
|
1788
|
+
project: String(fm.project || ws.project),
|
|
1789
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1790
|
+
topic: fm.topic || null,
|
|
1791
|
+
tags: fm.tags || [],
|
|
1792
|
+
content: body
|
|
1793
|
+
},
|
|
1794
|
+
timestamp: String(fm.completed_at || fm.date || "")
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
1798
|
+
}
|
|
1799
|
+
getDueReminders() {
|
|
1800
|
+
const ws = this.getWorkspace();
|
|
1801
|
+
const dir = this.typeDir(ws, "todo");
|
|
1802
|
+
const files = globMarkdown(dir);
|
|
1803
|
+
const today = todayDate2();
|
|
1804
|
+
const now = /* @__PURE__ */ new Date();
|
|
1805
|
+
const todayItems = [];
|
|
1806
|
+
const overdueItems = [];
|
|
1807
|
+
const recurringItems = [];
|
|
1808
|
+
for (const file of files) {
|
|
1809
|
+
const { frontmatter: fm, body } = readEntry(file);
|
|
1810
|
+
if (fm.status !== "pending") continue;
|
|
1811
|
+
const id = extractIdFromFilename(basename5(file));
|
|
1812
|
+
const entry = {
|
|
1813
|
+
id,
|
|
1814
|
+
type: "todo",
|
|
1815
|
+
date: String(fm.date || ""),
|
|
1816
|
+
project: String(fm.project || ws.project),
|
|
1817
|
+
workspace: String(fm.workspace || ws.workspace),
|
|
1818
|
+
topic: fm.topic || null,
|
|
1819
|
+
tags: fm.tags || [],
|
|
1820
|
+
content: this.extractTodoContent(body),
|
|
1821
|
+
status: "pending",
|
|
1822
|
+
priority: fm.priority || "normal",
|
|
1823
|
+
due: fm.due || null,
|
|
1824
|
+
remind_pattern: fm.remind_pattern || null,
|
|
1825
|
+
remind_last_done: fm.remind_last_done || null,
|
|
1826
|
+
completed_at: null
|
|
1827
|
+
};
|
|
1828
|
+
if (fm.due) {
|
|
1829
|
+
const due = String(fm.due);
|
|
1830
|
+
if (due === today) {
|
|
1831
|
+
todayItems.push(entry);
|
|
1832
|
+
} else if (due < today && !fm.remind_pattern) {
|
|
1833
|
+
overdueItems.push(entry);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
if (fm.remind_pattern) {
|
|
1837
|
+
const lastDone = fm.remind_last_done;
|
|
1838
|
+
if (!lastDone || lastDone < today) {
|
|
1839
|
+
if (matchesPattern2(String(fm.remind_pattern), now)) {
|
|
1840
|
+
recurringItems.push(entry);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (todayItems.length === 0 && overdueItems.length === 0 && recurringItems.length === 0) {
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
return {
|
|
1849
|
+
today: this.groupByProject(todayItems),
|
|
1850
|
+
overdue: this.groupByProject(overdueItems),
|
|
1851
|
+
recurring: this.groupByProject(recurringItems)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
getTags(filter) {
|
|
1855
|
+
const files = globMarkdown(this.baseDir);
|
|
1856
|
+
const tagCounts = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const file of files) {
|
|
1858
|
+
const { frontmatter: fm } = readEntry(file);
|
|
1859
|
+
const tags = fm.tags;
|
|
1860
|
+
if (!tags || !Array.isArray(tags)) continue;
|
|
1861
|
+
for (const tag of tags) {
|
|
1862
|
+
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
const results = Array.from(tagCounts.entries()).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count);
|
|
1866
|
+
if (filter) {
|
|
1867
|
+
return results.filter((r) => r.tag === filter);
|
|
1868
|
+
}
|
|
1869
|
+
return results;
|
|
1870
|
+
}
|
|
1871
|
+
getTimeline(filters) {
|
|
1872
|
+
const { from, to } = this.resolveDates({
|
|
1873
|
+
period: filters.period,
|
|
1874
|
+
from: filters.from,
|
|
1875
|
+
to: filters.to
|
|
1876
|
+
});
|
|
1877
|
+
const searchDir = filters.workspace ? join5(this.baseDir, filters.workspace) : this.baseDir;
|
|
1878
|
+
const files = globMarkdown(searchDir);
|
|
1879
|
+
const entries = [];
|
|
1880
|
+
for (const file of files) {
|
|
1881
|
+
const { frontmatter: fm, body } = readEntry(file);
|
|
1882
|
+
const date = String(fm.date || "");
|
|
1883
|
+
if (!date) continue;
|
|
1884
|
+
if (from && date < from.split("T")[0]) continue;
|
|
1885
|
+
if (to && date > to.split("T")[0]) continue;
|
|
1886
|
+
entries.push({
|
|
1887
|
+
type: fm.type || "note",
|
|
1888
|
+
date,
|
|
1889
|
+
workspace: String(fm.workspace || "default"),
|
|
1890
|
+
project: String(fm.project || "global"),
|
|
1891
|
+
summary: body.slice(0, 100).replace(/\n/g, " ")
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
return entries.sort((a, b) => b.date.localeCompare(a.date));
|
|
1895
|
+
}
|
|
1896
|
+
// ── Topics ──
|
|
1897
|
+
getTopics() {
|
|
1898
|
+
const files = globMarkdown(this.baseDir);
|
|
1899
|
+
const topicSet = /* @__PURE__ */ new Map();
|
|
1900
|
+
for (const file of files) {
|
|
1901
|
+
const { frontmatter: fm } = readEntry(file);
|
|
1902
|
+
if (fm.topic) {
|
|
1903
|
+
const name = String(fm.topic);
|
|
1904
|
+
topicSet.set(name, (topicSet.get(name) || 0) + 1);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return Array.from(topicSet.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([name]) => ({
|
|
1908
|
+
id: name,
|
|
1909
|
+
name,
|
|
1910
|
+
description: null,
|
|
1911
|
+
is_custom: true
|
|
1912
|
+
}));
|
|
1913
|
+
}
|
|
1914
|
+
insertTopic(name, _description) {
|
|
1915
|
+
return {
|
|
1916
|
+
id: name,
|
|
1917
|
+
name,
|
|
1918
|
+
description: _description || null,
|
|
1919
|
+
is_custom: true
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
// ── Code TODOs ──
|
|
1923
|
+
getCodeTodos(repoPath) {
|
|
1924
|
+
return scanCodeTodos(repoPath);
|
|
1925
|
+
}
|
|
1926
|
+
syncCodeTodos(_repoPath, _todos) {
|
|
1927
|
+
return { added: 0, resolved: 0 };
|
|
1928
|
+
}
|
|
1929
|
+
// ── Private helpers ──
|
|
1930
|
+
projectDir(ws) {
|
|
1931
|
+
return join5(this.baseDir, ws.workspace, ws.project);
|
|
1932
|
+
}
|
|
1933
|
+
typeDir(ws, type) {
|
|
1934
|
+
const dir = join5(this.projectDir(ws), TYPE_FOLDERS[type]);
|
|
1935
|
+
ensureDir(dir);
|
|
1936
|
+
return dir;
|
|
1937
|
+
}
|
|
1938
|
+
findFileById(dir, id) {
|
|
1939
|
+
const direct = join5(dir, `${id}.md`);
|
|
1940
|
+
if (existsSync4(direct)) return direct;
|
|
1941
|
+
const files = globMarkdown(dir);
|
|
1942
|
+
for (const file of files) {
|
|
1943
|
+
if (extractIdFromFilename(basename5(file)) === id) return file;
|
|
1944
|
+
}
|
|
1945
|
+
return null;
|
|
1946
|
+
}
|
|
1947
|
+
extractTodoContent(body) {
|
|
1948
|
+
const match = body.match(/^- \[[ x]\] (.+?)(?:\s*[\u23EB\uD83D\uDD3C\uD83D\uDD3D\uD83D\uDCC5\u2705].*)?$/m);
|
|
1949
|
+
return match ? match[1].trim() : body.trim();
|
|
1950
|
+
}
|
|
1951
|
+
filterAndMap(files, filters, mapper) {
|
|
1952
|
+
const results = [];
|
|
1953
|
+
for (const file of files) {
|
|
1954
|
+
const { frontmatter: fm, body } = readEntry(file);
|
|
1955
|
+
const date = String(fm.date || "");
|
|
1956
|
+
if (filters.from && date < filters.from) continue;
|
|
1957
|
+
if (filters.to && date > filters.to) continue;
|
|
1958
|
+
if (filters.topicId && fm.topic !== filters.topicId) continue;
|
|
1959
|
+
const id = extractIdFromFilename(basename5(file));
|
|
1960
|
+
results.push(mapper(fm, body, id));
|
|
1961
|
+
}
|
|
1962
|
+
results.sort((a, b) => {
|
|
1963
|
+
const dateA = a.date || "";
|
|
1964
|
+
const dateB = b.date || "";
|
|
1965
|
+
return dateB.localeCompare(dateA);
|
|
1966
|
+
});
|
|
1967
|
+
const limit = filters.limit ?? 100;
|
|
1968
|
+
return results.slice(0, limit);
|
|
1969
|
+
}
|
|
1970
|
+
groupByProject(items) {
|
|
1971
|
+
const map = /* @__PURE__ */ new Map();
|
|
1972
|
+
for (const item of items) {
|
|
1973
|
+
const key = item.project || "global";
|
|
1974
|
+
if (!map.has(key)) map.set(key, []);
|
|
1975
|
+
map.get(key).push(item);
|
|
1976
|
+
}
|
|
1977
|
+
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([name, reminders]) => ({
|
|
1978
|
+
repo_name: name === "global" ? null : name,
|
|
1979
|
+
reminders
|
|
1980
|
+
}));
|
|
1981
|
+
}
|
|
1982
|
+
resolveDates(filters) {
|
|
1983
|
+
if (filters.from || filters.to) {
|
|
1984
|
+
return { from: filters.from, to: filters.to };
|
|
1985
|
+
}
|
|
1986
|
+
const now = /* @__PURE__ */ new Date();
|
|
1987
|
+
const today = todayDate2();
|
|
1988
|
+
switch (filters.period) {
|
|
1989
|
+
case "today":
|
|
1990
|
+
return { from: today, to: today };
|
|
1991
|
+
case "yesterday": {
|
|
1992
|
+
const yesterday = new Date(now.getTime() - 864e5).toISOString().split("T")[0];
|
|
1993
|
+
return { from: yesterday, to: yesterday };
|
|
1994
|
+
}
|
|
1995
|
+
case "week": {
|
|
1996
|
+
const weekAgo = new Date(now.getTime() - 7 * 864e5).toISOString().split("T")[0];
|
|
1997
|
+
return { from: weekAgo, to: today };
|
|
1998
|
+
}
|
|
1999
|
+
case "month": {
|
|
2000
|
+
const monthAgo = new Date(now.getTime() - 30 * 864e5).toISOString().split("T")[0];
|
|
2001
|
+
return { from: monthAgo, to: today };
|
|
2002
|
+
}
|
|
2003
|
+
default:
|
|
2004
|
+
return { from: today, to: today };
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
// src/storage/index.ts
|
|
2010
|
+
var VALID_MODES = ["sqlite", "obsidian"];
|
|
2011
|
+
var instance = null;
|
|
2012
|
+
function getStorageMode() {
|
|
2013
|
+
const mode = process.env.LOGBOOK_STORAGE?.toLowerCase();
|
|
2014
|
+
if (!mode) return "sqlite";
|
|
2015
|
+
if (mode === "obsidian") return "obsidian";
|
|
2016
|
+
if (mode === "sqlite") return "sqlite";
|
|
2017
|
+
throw new Error(
|
|
2018
|
+
`LOGBOOK_STORAGE invalido: "${mode}". Valores validos: ${VALID_MODES.join(", ")}`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
function getStorage() {
|
|
2022
|
+
if (instance) return instance;
|
|
2023
|
+
const mode = getStorageMode();
|
|
2024
|
+
if (mode === "obsidian") {
|
|
2025
|
+
const dir = process.env.LOGBOOK_DIR;
|
|
2026
|
+
if (!dir) {
|
|
2027
|
+
throw new Error(
|
|
2028
|
+
"LOGBOOK_DIR es requerido cuando LOGBOOK_STORAGE=obsidian. Debe apuntar a la carpeta del vault de Obsidian."
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
instance = new ObsidianStorage(dir);
|
|
2032
|
+
} else {
|
|
2033
|
+
instance = new SqliteStorage();
|
|
2034
|
+
}
|
|
2035
|
+
return instance;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// src/tools/topics.ts
|
|
2039
|
+
function registerTopicsTool(server) {
|
|
2040
|
+
server.tool(
|
|
2041
|
+
"logbook_topics",
|
|
2042
|
+
"Lista o crea temas para organizar notas y TODOs. Temas predefinidos: feature, fix, chore, idea, decision, blocker.",
|
|
2043
|
+
{
|
|
2044
|
+
action: z.enum(["list", "add"]).default("list").describe("Accion: list (ver temas) o add (crear tema custom)"),
|
|
2045
|
+
name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, "Solo letras minusculas, numeros y guiones").optional().describe("Nombre del nuevo tema (solo para action=add, lowercase, sin espacios)"),
|
|
2046
|
+
description: z.string().max(200).optional().describe("Descripcion del nuevo tema (solo para action=add)")
|
|
2047
|
+
},
|
|
2048
|
+
async ({ action, name, description }) => {
|
|
2049
|
+
try {
|
|
2050
|
+
const storage = getStorage();
|
|
2051
|
+
if (action === "add") {
|
|
2052
|
+
if (!name) {
|
|
2053
|
+
return {
|
|
2054
|
+
isError: true,
|
|
2055
|
+
content: [{ type: "text", text: 'El parametro "name" es obligatorio para action=add' }]
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
const topics2 = storage.getTopics();
|
|
2059
|
+
if (topics2.some((t) => t.name === name)) {
|
|
2060
|
+
return {
|
|
2061
|
+
isError: true,
|
|
2062
|
+
content: [{ type: "text", text: `El topic "${name}" ya existe` }]
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
const topic = storage.insertTopic(name, description);
|
|
2066
|
+
return {
|
|
2067
|
+
content: [{ type: "text", text: JSON.stringify(topic) }]
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
const topics = storage.getTopics();
|
|
2071
|
+
return {
|
|
2072
|
+
content: [{ type: "text", text: JSON.stringify(topics) }]
|
|
2073
|
+
};
|
|
2074
|
+
} catch (err) {
|
|
2075
|
+
return {
|
|
2076
|
+
isError: true,
|
|
2077
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// src/tools/note.ts
|
|
2085
|
+
import { z as z2 } from "zod";
|
|
2086
|
+
function registerNoteTool(server) {
|
|
2087
|
+
server.tool(
|
|
2088
|
+
"logbook_note",
|
|
2089
|
+
"A\xF1ade una nota al logbook. Topics: feature, fix, chore, idea, decision, blocker. Si no se pasa topic, queda sin categorizar.",
|
|
2090
|
+
{
|
|
2091
|
+
content: z2.string().min(1).max(5e3).describe("Contenido de la nota"),
|
|
2092
|
+
topic: z2.string().optional().describe("Topic: feature, fix, chore, idea, decision, blocker (o custom)")
|
|
2093
|
+
},
|
|
2094
|
+
async ({ content, topic }) => {
|
|
2095
|
+
try {
|
|
2096
|
+
const storage = getStorage();
|
|
2097
|
+
storage.autoRegisterRepo();
|
|
2098
|
+
const note = storage.insertNote(content, topic);
|
|
2099
|
+
return {
|
|
2100
|
+
content: [{ type: "text", text: JSON.stringify(note) }]
|
|
2101
|
+
};
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
return {
|
|
2104
|
+
isError: true,
|
|
2105
|
+
content: [{ type: "text", text: `Error creando nota: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// src/tools/todo-add.ts
|
|
2113
|
+
import { z as z3 } from "zod";
|
|
2114
|
+
var priorityEnum = z3.enum(["low", "normal", "high", "urgent"]);
|
|
2115
|
+
function registerTodoAddTool(server) {
|
|
2116
|
+
server.tool(
|
|
2117
|
+
"logbook_todo_add",
|
|
2118
|
+
"Crea uno o varios TODOs. Para uno solo usa content. Para varios usa items (array). El topic se puede pasar o dejar que la AI lo infiera.",
|
|
2119
|
+
{
|
|
2120
|
+
content: z3.string().min(1).max(2e3).optional().describe("Contenido del TODO (para crear uno solo)"),
|
|
2121
|
+
topic: z3.string().optional().describe("Topic para el TODO individual"),
|
|
2122
|
+
priority: priorityEnum.optional().default("normal").describe("Prioridad del TODO individual"),
|
|
2123
|
+
remind_at: z3.string().optional().describe("Fecha recordatorio unica (YYYY-MM-DD)"),
|
|
2124
|
+
remind_pattern: z3.string().optional().describe("Patron recurrente: daily, weekdays, weekly:2 (martes), weekly:1,3 (lun+mie), monthly:1 (dia 1), monthly:1,15"),
|
|
2125
|
+
items: z3.array(
|
|
2126
|
+
z3.object({
|
|
2127
|
+
content: z3.string().min(1).max(2e3).describe("Contenido del TODO"),
|
|
2128
|
+
topic: z3.string().optional().describe("Topic"),
|
|
2129
|
+
priority: priorityEnum.optional().default("normal").describe("Prioridad"),
|
|
2130
|
+
remind_at: z3.string().optional().describe("Fecha recordatorio (YYYY-MM-DD)"),
|
|
2131
|
+
remind_pattern: z3.string().optional().describe("Patron recurrente")
|
|
2132
|
+
})
|
|
2133
|
+
).max(50).optional().describe("Array de TODOs para crear varios a la vez (max 50)")
|
|
2134
|
+
},
|
|
2135
|
+
async ({ content, topic, priority, remind_at, remind_pattern, items }) => {
|
|
2136
|
+
try {
|
|
2137
|
+
if (!content && (!items || items.length === 0)) {
|
|
2138
|
+
return {
|
|
2139
|
+
isError: true,
|
|
2140
|
+
content: [{
|
|
2141
|
+
type: "text",
|
|
2142
|
+
text: 'Debes pasar "content" (para uno) o "items" (para varios)'
|
|
2143
|
+
}]
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
const storage = getStorage();
|
|
2147
|
+
storage.autoRegisterRepo();
|
|
2148
|
+
const todoItems = items ? items : [{ content, topic, priority: priority ?? "normal", remind_at, remind_pattern }];
|
|
2149
|
+
const results = [];
|
|
2150
|
+
for (const item of todoItems) {
|
|
2151
|
+
const hasReminder = item.remind_at || item.remind_pattern;
|
|
2152
|
+
const effectiveTopic = hasReminder && !item.topic ? "reminder" : item.topic;
|
|
2153
|
+
const todo = storage.insertTodo(item.content, {
|
|
2154
|
+
topic: effectiveTopic,
|
|
2155
|
+
priority: item.priority ?? "normal",
|
|
2156
|
+
remind_at: item.remind_at,
|
|
2157
|
+
remind_pattern: item.remind_pattern
|
|
2158
|
+
});
|
|
2159
|
+
results.push(todo);
|
|
2160
|
+
}
|
|
2161
|
+
return {
|
|
2162
|
+
content: [{
|
|
2163
|
+
type: "text",
|
|
2164
|
+
text: JSON.stringify(
|
|
2165
|
+
results.length === 1 ? results[0] : { created: results.length, todos: results }
|
|
2166
|
+
)
|
|
2167
|
+
}]
|
|
2168
|
+
};
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
return {
|
|
2171
|
+
isError: true,
|
|
2172
|
+
content: [{ type: "text", text: `Error creando TODO: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// src/tools/todo-list.ts
|
|
2180
|
+
import { z as z4 } from "zod";
|
|
2181
|
+
function registerTodoListTool(server) {
|
|
2182
|
+
server.tool(
|
|
2183
|
+
"logbook_todo_list",
|
|
2184
|
+
"Lista TODOs agrupados por topic. Incluye manuales y del codigo (TODO/FIXME/HACK/BUG). Sincroniza automaticamente: code TODOs que desaparecen del codigo se marcan como resueltos.",
|
|
2185
|
+
{
|
|
2186
|
+
status: z4.enum(["pending", "done", "all"]).optional().default("pending").describe("Filtrar por estado (default: pending)"),
|
|
2187
|
+
topic: z4.string().optional().describe("Filtrar por topic"),
|
|
2188
|
+
priority: z4.enum(["low", "normal", "high", "urgent"]).optional().describe("Filtrar por prioridad"),
|
|
2189
|
+
source: z4.enum(["all", "manual", "code"]).optional().default("all").describe("Filtrar por origen: manual (DB), code (git grep), all"),
|
|
2190
|
+
scope: z4.enum(["project", "global"]).optional().default("project").describe("Scope: project (auto-detecta) o global (todos los proyectos)"),
|
|
2191
|
+
from: z4.string().optional().describe("Desde fecha (YYYY-MM-DD)"),
|
|
2192
|
+
to: z4.string().optional().describe("Hasta fecha (YYYY-MM-DD)"),
|
|
2193
|
+
limit: z4.number().optional().default(100).describe("Maximo resultados manuales")
|
|
2194
|
+
},
|
|
2195
|
+
async ({ status, topic, priority, source, scope, from, to, limit }) => {
|
|
2196
|
+
try {
|
|
2197
|
+
const storage = getStorage();
|
|
2198
|
+
if (scope === "project") storage.autoRegisterRepo();
|
|
2199
|
+
const manualTodos = source === "code" ? [] : storage.getTodos({
|
|
2200
|
+
status,
|
|
2201
|
+
topicId: topic,
|
|
2202
|
+
priority,
|
|
2203
|
+
from,
|
|
2204
|
+
to,
|
|
2205
|
+
limit
|
|
2206
|
+
});
|
|
2207
|
+
let codeTodos = [];
|
|
2208
|
+
let syncResult = null;
|
|
2209
|
+
if (source !== "manual" && status !== "done") {
|
|
2210
|
+
const repoPath = scope === "project" ? detectRepoPath() : null;
|
|
2211
|
+
if (repoPath) {
|
|
2212
|
+
codeTodos = storage.getCodeTodos(repoPath);
|
|
2213
|
+
syncResult = storage.syncCodeTodos(repoPath, codeTodos);
|
|
2214
|
+
if (topic) {
|
|
2215
|
+
codeTodos = codeTodos.filter((ct) => ct.topic_name === topic);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
2220
|
+
for (const todo of manualTodos) {
|
|
2221
|
+
const key = todo.topic ?? "sin-topic";
|
|
2222
|
+
if (!groupMap.has(key)) groupMap.set(key, []);
|
|
2223
|
+
groupMap.get(key).push(todo);
|
|
2224
|
+
}
|
|
2225
|
+
for (const ct of codeTodos) {
|
|
2226
|
+
const key = ct.topic_name;
|
|
2227
|
+
if (!groupMap.has(key)) groupMap.set(key, []);
|
|
2228
|
+
groupMap.get(key).push(ct);
|
|
2229
|
+
}
|
|
2230
|
+
const groups = Array.from(groupMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([topicName, items]) => ({ topic: topicName, items }));
|
|
2231
|
+
return {
|
|
2232
|
+
content: [{
|
|
2233
|
+
type: "text",
|
|
2234
|
+
text: JSON.stringify({
|
|
2235
|
+
groups,
|
|
2236
|
+
summary: {
|
|
2237
|
+
manual: manualTodos.length,
|
|
2238
|
+
code: codeTodos.length,
|
|
2239
|
+
total: manualTodos.length + codeTodos.length,
|
|
2240
|
+
...syncResult && (syncResult.added > 0 || syncResult.resolved > 0) ? { sync: syncResult } : {}
|
|
2241
|
+
}
|
|
2242
|
+
})
|
|
2243
|
+
}]
|
|
2244
|
+
};
|
|
2245
|
+
} catch (err) {
|
|
2246
|
+
return {
|
|
2247
|
+
isError: true,
|
|
2248
|
+
content: [{ type: "text", text: `Error listando TODOs: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// src/tools/todo-done.ts
|
|
2256
|
+
import { z as z5 } from "zod";
|
|
2257
|
+
function registerTodoDoneTool(server) {
|
|
2258
|
+
server.tool(
|
|
2259
|
+
"logbook_todo_done",
|
|
2260
|
+
"Marca TODOs como hechos o los devuelve a pendiente (undo). Los recordatorios recurrentes se marcan como hechos por hoy y vuelven automaticamente el proximo dia que toque.",
|
|
2261
|
+
{
|
|
2262
|
+
ids: z5.union([z5.number(), z5.string(), z5.array(z5.union([z5.number(), z5.string()]))]).describe("ID o array de IDs de TODOs a marcar"),
|
|
2263
|
+
undo: z5.boolean().optional().default(false).describe("Si true, devuelve a pendiente en vez de marcar como hecho")
|
|
2264
|
+
},
|
|
2265
|
+
async ({ ids, undo }) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const storage = getStorage();
|
|
2268
|
+
storage.autoRegisterRepo();
|
|
2269
|
+
const idArray = (Array.isArray(ids) ? ids : [ids]).map(String);
|
|
2270
|
+
const allTodos = storage.getTodos({ status: "all" });
|
|
2271
|
+
const todoMap = new Map(allTodos.map((t) => [t.id, t]));
|
|
2272
|
+
const regularIds = [];
|
|
2273
|
+
const recurringIds = [];
|
|
2274
|
+
for (const id of idArray) {
|
|
2275
|
+
const todo = todoMap.get(id);
|
|
2276
|
+
if (todo?.remind_pattern && !undo) {
|
|
2277
|
+
recurringIds.push(id);
|
|
2278
|
+
} else {
|
|
2279
|
+
regularIds.push(id);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
const results = [];
|
|
2283
|
+
if (regularIds.length > 0) {
|
|
2284
|
+
const status = undo ? "pending" : "done";
|
|
2285
|
+
const updated = storage.updateTodoStatus(regularIds, status);
|
|
2286
|
+
results.push(...updated);
|
|
2287
|
+
}
|
|
2288
|
+
for (const id of recurringIds) {
|
|
2289
|
+
storage.ackRecurringReminder(id);
|
|
2290
|
+
const todo = todoMap.get(id);
|
|
2291
|
+
if (todo) results.push(todo);
|
|
2292
|
+
}
|
|
2293
|
+
return {
|
|
2294
|
+
content: [{
|
|
2295
|
+
type: "text",
|
|
2296
|
+
text: JSON.stringify({
|
|
2297
|
+
action: undo ? "undo" : "done",
|
|
2298
|
+
updated: results.length,
|
|
2299
|
+
recurring_acked: recurringIds.length,
|
|
2300
|
+
todos: results
|
|
2301
|
+
})
|
|
2302
|
+
}]
|
|
2303
|
+
};
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
return {
|
|
2306
|
+
isError: true,
|
|
2307
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// src/tools/todo-edit.ts
|
|
2315
|
+
import { z as z6 } from "zod";
|
|
2316
|
+
function registerTodoEditTool(server) {
|
|
2317
|
+
server.tool(
|
|
2318
|
+
"logbook_todo_edit",
|
|
2319
|
+
"Edita un TODO existente: contenido, topic o prioridad.",
|
|
2320
|
+
{
|
|
2321
|
+
id: z6.union([z6.number(), z6.string()]).describe("ID del TODO a editar"),
|
|
2322
|
+
content: z6.string().optional().describe("Nuevo contenido"),
|
|
2323
|
+
topic: z6.string().optional().describe("Nuevo topic"),
|
|
2324
|
+
priority: z6.enum(["low", "normal", "high", "urgent"]).optional().describe("Nueva prioridad")
|
|
2325
|
+
},
|
|
2326
|
+
async ({ id, content, topic, priority }) => {
|
|
2327
|
+
try {
|
|
2328
|
+
const storage = getStorage();
|
|
2329
|
+
storage.autoRegisterRepo();
|
|
2330
|
+
const updated = storage.updateTodo(String(id), { content, topic, priority });
|
|
2331
|
+
if (!updated) {
|
|
2332
|
+
return {
|
|
2333
|
+
isError: true,
|
|
2334
|
+
content: [{ type: "text", text: `TODO #${id} no encontrado o sin cambios` }]
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
return {
|
|
2338
|
+
content: [{ type: "text", text: JSON.stringify(updated) }]
|
|
2339
|
+
};
|
|
2340
|
+
} catch (err) {
|
|
2341
|
+
return {
|
|
2342
|
+
isError: true,
|
|
2343
|
+
content: [{ type: "text", text: `Error editando TODO: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// src/tools/todo-rm.ts
|
|
2351
|
+
import { z as z7 } from "zod";
|
|
2352
|
+
function registerTodoRmTool(server) {
|
|
2353
|
+
server.tool(
|
|
2354
|
+
"logbook_todo_rm",
|
|
2355
|
+
"Elimina TODOs por ID. Acepta uno o varios IDs.",
|
|
2356
|
+
{
|
|
2357
|
+
ids: z7.union([z7.number(), z7.string(), z7.array(z7.union([z7.number(), z7.string()]))]).describe("ID o array de IDs de TODOs a eliminar")
|
|
2358
|
+
},
|
|
2359
|
+
async ({ ids }) => {
|
|
2360
|
+
try {
|
|
2361
|
+
const storage = getStorage();
|
|
2362
|
+
storage.autoRegisterRepo();
|
|
2363
|
+
const idArray = (Array.isArray(ids) ? ids : [ids]).map(String);
|
|
2364
|
+
const deleted = storage.deleteTodos(idArray);
|
|
2365
|
+
return {
|
|
2366
|
+
content: [{
|
|
2367
|
+
type: "text",
|
|
2368
|
+
text: JSON.stringify({
|
|
2369
|
+
deleted: deleted.length,
|
|
2370
|
+
ids: deleted
|
|
2371
|
+
})
|
|
2372
|
+
}]
|
|
2373
|
+
};
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
return {
|
|
2376
|
+
isError: true,
|
|
2377
|
+
content: [{ type: "text", text: `Error eliminando TODOs: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// src/tools/log.ts
|
|
2385
|
+
import { z as z8 } from "zod";
|
|
2386
|
+
function registerLogTool(server) {
|
|
2387
|
+
server.tool(
|
|
2388
|
+
"logbook_log",
|
|
2389
|
+
"Muestra actividad: notas y TODOs completados para un periodo. Por defecto muestra el dia de hoy del proyecto actual.",
|
|
2390
|
+
{
|
|
2391
|
+
period: z8.enum(["today", "yesterday", "week", "month"]).optional().describe("Periodo predefinido (default: today)"),
|
|
2392
|
+
from: z8.string().optional().describe("Desde fecha (YYYY-MM-DD). Sobreescribe period."),
|
|
2393
|
+
to: z8.string().optional().describe("Hasta fecha (YYYY-MM-DD). Sobreescribe period."),
|
|
2394
|
+
type: z8.enum(["all", "notes", "todos"]).optional().default("all").describe("Filtrar por tipo: all, notes, todos"),
|
|
2395
|
+
topic: z8.string().optional().describe("Filtrar por topic"),
|
|
2396
|
+
scope: z8.enum(["project", "global"]).optional().default("project").describe("Scope: project (auto-detecta) o global")
|
|
2397
|
+
},
|
|
2398
|
+
async ({ period, from, to, type, topic, scope }) => {
|
|
2399
|
+
try {
|
|
2400
|
+
const storage = getStorage();
|
|
2401
|
+
const repo = scope === "project" ? storage.autoRegisterRepo() : null;
|
|
2402
|
+
const entries = storage.getLog({
|
|
2403
|
+
period,
|
|
2404
|
+
from,
|
|
2405
|
+
to,
|
|
2406
|
+
type,
|
|
2407
|
+
topic,
|
|
2408
|
+
scope
|
|
2409
|
+
});
|
|
2410
|
+
return {
|
|
2411
|
+
content: [{
|
|
2412
|
+
type: "text",
|
|
2413
|
+
text: JSON.stringify({
|
|
2414
|
+
period: from ? `${from} \u2014 ${to || from}` : period ?? "today",
|
|
2415
|
+
scope: scope ?? "project",
|
|
2416
|
+
project: repo?.name ?? null,
|
|
2417
|
+
entries,
|
|
2418
|
+
summary: {
|
|
2419
|
+
total: entries.length
|
|
2420
|
+
}
|
|
2421
|
+
})
|
|
2422
|
+
}]
|
|
2423
|
+
};
|
|
2424
|
+
} catch (err) {
|
|
2425
|
+
return {
|
|
2426
|
+
isError: true,
|
|
2427
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// src/tools/search.ts
|
|
2435
|
+
import { z as z9 } from "zod";
|
|
2436
|
+
function registerSearchTool(server) {
|
|
2437
|
+
server.tool(
|
|
2438
|
+
"logbook_search",
|
|
2439
|
+
"Busqueda full-text en notas y TODOs. Usa FTS5 para busqueda rapida.",
|
|
2440
|
+
{
|
|
2441
|
+
query: z9.string().min(1).max(500).describe("Texto a buscar"),
|
|
2442
|
+
type: z9.enum(["all", "notes", "todos"]).optional().default("all").describe("Buscar en: all, notes, todos"),
|
|
2443
|
+
topic: z9.string().optional().describe("Filtrar por topic"),
|
|
2444
|
+
scope: z9.enum(["project", "global"]).optional().default("project").describe("Scope: project o global"),
|
|
2445
|
+
limit: z9.number().optional().default(20).describe("Maximo resultados (default: 20)")
|
|
2446
|
+
},
|
|
2447
|
+
async ({ query, type, topic, scope, limit }) => {
|
|
2448
|
+
try {
|
|
2449
|
+
const storage = getStorage();
|
|
2450
|
+
if (scope === "project") storage.autoRegisterRepo();
|
|
2451
|
+
const results = storage.search(query, { type, topic, scope, limit });
|
|
2452
|
+
return {
|
|
2453
|
+
content: [{
|
|
2454
|
+
type: "text",
|
|
2455
|
+
text: JSON.stringify({
|
|
2456
|
+
query,
|
|
2457
|
+
results,
|
|
2458
|
+
total: results.length
|
|
2459
|
+
})
|
|
2460
|
+
}]
|
|
2461
|
+
};
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
return {
|
|
2464
|
+
isError: true,
|
|
2465
|
+
content: [{ type: "text", text: `Error buscando: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// src/tools/standup.ts
|
|
2473
|
+
import { z as z10 } from "zod";
|
|
2474
|
+
function registerStandupTool(server) {
|
|
2475
|
+
server.tool(
|
|
2476
|
+
"logbook_standup",
|
|
2477
|
+
"Registra un standup diario con lo que se hizo ayer, lo de hoy y blockers. Ideal para Daily Notes en Obsidian.",
|
|
2478
|
+
{
|
|
2479
|
+
yesterday: z10.string().min(1).max(2e3).describe("Lo que se hizo ayer"),
|
|
2480
|
+
today: z10.string().min(1).max(2e3).describe("Lo que se va a hacer hoy"),
|
|
2481
|
+
blockers: z10.string().max(2e3).optional().default("").describe("Blockers actuales (opcional)"),
|
|
2482
|
+
topic: z10.string().optional().describe("Topic (opcional)")
|
|
2483
|
+
},
|
|
2484
|
+
async ({ yesterday, today, blockers, topic }) => {
|
|
2485
|
+
try {
|
|
2486
|
+
const storage = getStorage();
|
|
2487
|
+
storage.autoRegisterRepo();
|
|
2488
|
+
const standup = storage.insertStandup(yesterday, today, blockers, topic);
|
|
2489
|
+
return {
|
|
2490
|
+
content: [{ type: "text", text: JSON.stringify(standup) }]
|
|
2491
|
+
};
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
return {
|
|
2494
|
+
isError: true,
|
|
2495
|
+
content: [{ type: "text", text: `Error creando standup: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/tools/decision.ts
|
|
2503
|
+
import { z as z11 } from "zod";
|
|
2504
|
+
function registerDecisionTool(server) {
|
|
2505
|
+
server.tool(
|
|
2506
|
+
"logbook_decision",
|
|
2507
|
+
"Registra una decision arquitectonica o tecnica (ADR). Incluye contexto, opciones, decision y consecuencias.",
|
|
2508
|
+
{
|
|
2509
|
+
title: z11.string().min(1).max(200).describe("Titulo de la decision"),
|
|
2510
|
+
context: z11.string().min(1).max(3e3).describe("Contexto: por que se necesita esta decision"),
|
|
2511
|
+
options: z11.array(z11.string().min(1).max(500)).min(1).max(10).describe("Opciones consideradas"),
|
|
2512
|
+
decision: z11.string().min(1).max(2e3).describe("Decision tomada"),
|
|
2513
|
+
consequences: z11.string().min(1).max(2e3).describe("Consecuencias de la decision"),
|
|
2514
|
+
topic: z11.string().optional().describe("Topic (default: decision)")
|
|
2515
|
+
},
|
|
2516
|
+
async ({ title, context, options, decision, consequences, topic }) => {
|
|
2517
|
+
try {
|
|
2518
|
+
const storage = getStorage();
|
|
2519
|
+
storage.autoRegisterRepo();
|
|
2520
|
+
const entry = storage.insertDecision(title, context, options, decision, consequences, topic);
|
|
2521
|
+
return {
|
|
2522
|
+
content: [{ type: "text", text: JSON.stringify(entry) }]
|
|
2523
|
+
};
|
|
2524
|
+
} catch (err) {
|
|
2525
|
+
return {
|
|
2526
|
+
isError: true,
|
|
2527
|
+
content: [{ type: "text", text: `Error creando decision: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/tools/debug.ts
|
|
2535
|
+
import { z as z12 } from "zod";
|
|
2536
|
+
function registerDebugTool(server) {
|
|
2537
|
+
server.tool(
|
|
2538
|
+
"logbook_debug",
|
|
2539
|
+
"Registra una sesion de debug: error, causa, fix y archivo adjunto opcional. En Obsidian se muestra como callout [!bug].",
|
|
2540
|
+
{
|
|
2541
|
+
title: z12.string().min(1).max(200).describe("Titulo del bug/error"),
|
|
2542
|
+
error: z12.string().min(1).max(3e3).describe("Descripcion del error"),
|
|
2543
|
+
cause: z12.string().min(1).max(3e3).describe("Causa raiz identificada"),
|
|
2544
|
+
fix: z12.string().min(1).max(3e3).describe("Solucion aplicada"),
|
|
2545
|
+
file: z12.string().optional().describe("Ruta a archivo adjunto (screenshot, log). Se copia al vault."),
|
|
2546
|
+
topic: z12.string().optional().describe("Topic (default: fix)")
|
|
2547
|
+
},
|
|
2548
|
+
async ({ title, error, cause, fix, file, topic }) => {
|
|
2549
|
+
try {
|
|
2550
|
+
const storage = getStorage();
|
|
2551
|
+
storage.autoRegisterRepo();
|
|
2552
|
+
const entry = storage.insertDebug(title, error, cause, fix, file, topic);
|
|
2553
|
+
return {
|
|
2554
|
+
content: [{ type: "text", text: JSON.stringify(entry) }]
|
|
2555
|
+
};
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
return {
|
|
2558
|
+
isError: true,
|
|
2559
|
+
content: [{ type: "text", text: `Error creando debug entry: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// src/tools/tags.ts
|
|
2567
|
+
import { z as z13 } from "zod";
|
|
2568
|
+
function registerTagsTool(server) {
|
|
2569
|
+
server.tool(
|
|
2570
|
+
"logbook_tags",
|
|
2571
|
+
"Lista todos los tags usados en el logbook con su conteo. Opcionalmente filtra por un tag especifico.",
|
|
2572
|
+
{
|
|
2573
|
+
filter: z13.string().optional().describe("Filtrar por tag especifico (opcional)")
|
|
2574
|
+
},
|
|
2575
|
+
async ({ filter }) => {
|
|
2576
|
+
try {
|
|
2577
|
+
const storage = getStorage();
|
|
2578
|
+
storage.autoRegisterRepo();
|
|
2579
|
+
const tags = storage.getTags(filter);
|
|
2580
|
+
return {
|
|
2581
|
+
content: [{ type: "text", text: JSON.stringify({ tags, total: tags.length }) }]
|
|
2582
|
+
};
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
return {
|
|
2585
|
+
isError: true,
|
|
2586
|
+
content: [{ type: "text", text: `Error listando tags: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/tools/timeline.ts
|
|
2594
|
+
import { z as z14 } from "zod";
|
|
2595
|
+
function registerTimelineTool(server) {
|
|
2596
|
+
server.tool(
|
|
2597
|
+
"logbook_timeline",
|
|
2598
|
+
"Timeline de actividad cruzando proyectos y workspaces. Muestra un resumen cronologico de toda la actividad.",
|
|
2599
|
+
{
|
|
2600
|
+
period: z14.enum(["today", "yesterday", "week", "month"]).optional().default("week").describe("Periodo (default: week)"),
|
|
2601
|
+
from: z14.string().optional().describe("Desde fecha (YYYY-MM-DD). Sobreescribe period."),
|
|
2602
|
+
to: z14.string().optional().describe("Hasta fecha (YYYY-MM-DD). Sobreescribe period."),
|
|
2603
|
+
workspace: z14.string().optional().describe("Filtrar por workspace (opcional)")
|
|
2604
|
+
},
|
|
2605
|
+
async ({ period, from, to, workspace }) => {
|
|
2606
|
+
try {
|
|
2607
|
+
const storage = getStorage();
|
|
2608
|
+
storage.autoRegisterRepo();
|
|
2609
|
+
const entries = storage.getTimeline({ period, from, to, workspace });
|
|
2610
|
+
return {
|
|
2611
|
+
content: [{
|
|
2612
|
+
type: "text",
|
|
2613
|
+
text: JSON.stringify({
|
|
2614
|
+
period: from ? `${from} \u2014 ${to || from}` : period,
|
|
2615
|
+
entries,
|
|
2616
|
+
total: entries.length
|
|
2617
|
+
})
|
|
2618
|
+
}]
|
|
2619
|
+
};
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
return {
|
|
2622
|
+
isError: true,
|
|
2623
|
+
content: [{ type: "text", text: `Error generando timeline: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// src/tools/migrate.ts
|
|
2631
|
+
function registerMigrateTool(server) {
|
|
2632
|
+
server.tool(
|
|
2633
|
+
"logbook_migrate",
|
|
2634
|
+
"Convierte datos existentes de SQLite a archivos markdown para Obsidian. Requiere LOGBOOK_STORAGE=obsidian y que exista la base de datos SQLite.",
|
|
2635
|
+
{},
|
|
2636
|
+
async () => {
|
|
2637
|
+
try {
|
|
2638
|
+
if (getStorageMode() !== "obsidian") {
|
|
2639
|
+
return {
|
|
2640
|
+
isError: true,
|
|
2641
|
+
content: [{
|
|
2642
|
+
type: "text",
|
|
2643
|
+
text: "logbook_migrate requiere LOGBOOK_STORAGE=obsidian. Configura las variables de entorno antes de migrar."
|
|
2644
|
+
}]
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
let db2;
|
|
2648
|
+
try {
|
|
2649
|
+
db2 = getDb();
|
|
2650
|
+
} catch {
|
|
2651
|
+
return {
|
|
2652
|
+
isError: true,
|
|
2653
|
+
content: [{
|
|
2654
|
+
type: "text",
|
|
2655
|
+
text: "No se encontro la base de datos SQLite en ~/.logbook/logbook.db"
|
|
2656
|
+
}]
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
const notes = getNotes(db2, { limit: 1e4 });
|
|
2660
|
+
const todos = getTodos(db2, { limit: 1e4 });
|
|
2661
|
+
const storage = getStorage();
|
|
2662
|
+
storage.autoRegisterRepo();
|
|
2663
|
+
let migratedNotes = 0;
|
|
2664
|
+
let migratedTodos = 0;
|
|
2665
|
+
for (const note of notes) {
|
|
2666
|
+
try {
|
|
2667
|
+
storage.insertNote(note.content, note.topic_name || void 0);
|
|
2668
|
+
migratedNotes++;
|
|
2669
|
+
} catch {
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
for (const todo of todos) {
|
|
2673
|
+
try {
|
|
2674
|
+
const entry = storage.insertTodo(todo.content, {
|
|
2675
|
+
topic: todo.topic_name || void 0,
|
|
2676
|
+
priority: todo.priority,
|
|
2677
|
+
remind_at: todo.remind_at || void 0,
|
|
2678
|
+
remind_pattern: todo.remind_pattern || void 0
|
|
2679
|
+
});
|
|
2680
|
+
if (todo.status === "done") {
|
|
2681
|
+
storage.updateTodoStatus([entry.id], "done");
|
|
2682
|
+
}
|
|
2683
|
+
migratedTodos++;
|
|
2684
|
+
} catch {
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return {
|
|
2688
|
+
content: [{
|
|
2689
|
+
type: "text",
|
|
2690
|
+
text: JSON.stringify({
|
|
2691
|
+
migrated: {
|
|
2692
|
+
notes: migratedNotes,
|
|
2693
|
+
todos: migratedTodos,
|
|
2694
|
+
total: migratedNotes + migratedTodos
|
|
2695
|
+
},
|
|
2696
|
+
source: "sqlite (~/.logbook/logbook.db)",
|
|
2697
|
+
destination: process.env.LOGBOOK_DIR
|
|
2698
|
+
})
|
|
2699
|
+
}]
|
|
2700
|
+
};
|
|
2701
|
+
} catch (err) {
|
|
2702
|
+
return {
|
|
2703
|
+
isError: true,
|
|
2704
|
+
content: [{ type: "text", text: `Error migrando: ${err instanceof Error ? err.message : String(err)}` }]
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/resources/reminders.ts
|
|
2712
|
+
function registerRemindersResource(server) {
|
|
2713
|
+
server.resource(
|
|
2714
|
+
"reminders",
|
|
2715
|
+
"logbook://reminders",
|
|
2716
|
+
{
|
|
2717
|
+
description: "Recordatorios pendientes para hoy y atrasados, agrupados por proyecto. Vacio si no hay ninguno.",
|
|
2718
|
+
mimeType: "application/json"
|
|
2719
|
+
},
|
|
2720
|
+
async (uri) => {
|
|
2721
|
+
try {
|
|
2722
|
+
const storage = getStorage();
|
|
2723
|
+
storage.autoRegisterRepo();
|
|
2724
|
+
const result = storage.getDueReminders();
|
|
2725
|
+
if (!result) {
|
|
2726
|
+
return { contents: [] };
|
|
2727
|
+
}
|
|
2728
|
+
for (const group of result.recurring) {
|
|
2729
|
+
for (const reminder of group.reminders) {
|
|
2730
|
+
storage.ackRecurringReminder(reminder.id);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
return {
|
|
2734
|
+
contents: [{
|
|
2735
|
+
uri: uri.href,
|
|
2736
|
+
text: JSON.stringify(result)
|
|
2737
|
+
}]
|
|
2738
|
+
};
|
|
2739
|
+
} catch {
|
|
2740
|
+
return { contents: [] };
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/server.ts
|
|
2747
|
+
var VERSION = true ? "0.5.1" : "0.0.0";
|
|
2748
|
+
function createServer() {
|
|
2749
|
+
const server = new McpServer({
|
|
2750
|
+
name: "logbook-mcp",
|
|
2751
|
+
version: VERSION
|
|
2752
|
+
});
|
|
2753
|
+
registerTopicsTool(server);
|
|
2754
|
+
registerNoteTool(server);
|
|
2755
|
+
registerTodoAddTool(server);
|
|
2756
|
+
registerTodoListTool(server);
|
|
2757
|
+
registerTodoDoneTool(server);
|
|
2758
|
+
registerTodoEditTool(server);
|
|
2759
|
+
registerTodoRmTool(server);
|
|
2760
|
+
registerLogTool(server);
|
|
2761
|
+
registerSearchTool(server);
|
|
2762
|
+
registerStandupTool(server);
|
|
2763
|
+
registerDecisionTool(server);
|
|
2764
|
+
registerDebugTool(server);
|
|
2765
|
+
registerTagsTool(server);
|
|
2766
|
+
registerTimelineTool(server);
|
|
2767
|
+
registerMigrateTool(server);
|
|
2768
|
+
registerRemindersResource(server);
|
|
2769
|
+
return server;
|
|
2770
|
+
}
|
|
2771
|
+
export {
|
|
2772
|
+
createServer
|
|
2773
|
+
};
|