studylens 0.1.1 → 0.1.3

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.
@@ -1,414 +1,414 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { v4: uuidv4 } = require('uuid');
4
-
5
- const WIKI_ROOT = process.env.STUDYLENS_WIKI_DIR || path.join(__dirname, '..', 'wiki');
6
- const RAW_DIR = path.join(WIKI_ROOT, 'raw');
7
- const DRILL_CORE = path.join(WIKI_ROOT, 'drill', 'core');
8
- const DRILL_EXT = path.join(WIKI_ROOT, 'drill', 'extended');
9
- const INDEX_DIR = path.join(WIKI_ROOT, 'index');
10
- const TAGS_DIR = path.join(INDEX_DIR, 'tags');
11
- const TOPICS_DIR = path.join(WIKI_ROOT, 'topics');
12
- const QA_DIR = path.join(WIKI_ROOT, 'qa');
13
-
14
- function ensureDirs() {
15
- for (const d of [RAW_DIR, DRILL_CORE, DRILL_EXT, INDEX_DIR, TAGS_DIR, TOPICS_DIR, QA_DIR]) {
16
- fs.mkdirSync(d, { recursive: true });
17
- }
18
- }
19
- ensureDirs();
20
-
21
- function sanitize(name) {
22
- return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 60);
23
- }
24
-
25
- function readJson(filePath, fallback) {
26
- try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return fallback; }
27
- }
28
-
29
- function writeJson(filePath, data) {
30
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
31
- }
32
-
33
- // --- Index management ---
34
-
35
- function getConnections() {
36
- return readJson(path.join(INDEX_DIR, 'connections.json'), []);
37
- }
38
-
39
- function saveConnections(conns) {
40
- writeJson(path.join(INDEX_DIR, 'connections.json'), conns);
41
- }
42
-
43
- function getEntryIndex() {
44
- return readJson(path.join(INDEX_DIR, 'entries.json'), []);
45
- }
46
-
47
- function saveEntryIndex(entries) {
48
- writeJson(path.join(INDEX_DIR, 'entries.json'), entries);
49
- }
50
-
51
- // --- Tag index ---
52
-
53
- function getTagIndex(tag) {
54
- return readJson(path.join(TAGS_DIR, `${sanitize(tag)}.json`), []);
55
- }
56
-
57
- function saveTagIndex(tag, entries) {
58
- writeJson(path.join(TAGS_DIR, `${sanitize(tag)}.json`), entries);
59
- }
60
-
61
- function addToTagIndex(entryId, title, subject, tags) {
62
- for (const tag of tags) {
63
- const idx = getTagIndex(tag);
64
- if (!idx.some(e => e.id === entryId)) {
65
- idx.push({ id: entryId, title, subject });
66
- saveTagIndex(tag, idx);
67
- }
68
- }
69
- }
70
-
71
- function removeFromTagIndex(entryId, tags) {
72
- for (const tag of tags) {
73
- const idx = getTagIndex(tag).filter(e => e.id !== entryId);
74
- saveTagIndex(tag, idx);
75
- }
76
- }
77
-
78
- function rebuildTagIndex() {
79
- const entries = getAllEntries();
80
- const tagMap = {};
81
- for (const e of entries) {
82
- for (const tag of (e.tags || [])) {
83
- if (!tagMap[tag]) tagMap[tag] = [];
84
- tagMap[tag].push({ id: e.id, title: e.title, subject: e.subject });
85
- }
86
- }
87
- // Clear old tag files
88
- if (fs.existsSync(TAGS_DIR)) {
89
- for (const f of fs.readdirSync(TAGS_DIR)) fs.unlinkSync(path.join(TAGS_DIR, f));
90
- }
91
- for (const [tag, entries] of Object.entries(tagMap)) {
92
- saveTagIndex(tag, entries);
93
- }
94
- return Object.keys(tagMap).length;
95
- }
96
-
97
- // --- Raw layer ---
98
-
99
- function addRaw(text, sourceType, sourceRef) {
100
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
101
- const name = `${ts}_${sanitize(sourceRef || sourceType)}`;
102
- const filePath = path.join(RAW_DIR, `${name}.md`);
103
- const frontmatter = `---
104
- source_type: ${sourceType}
105
- source_ref: ${sourceRef}
106
- created_at: ${new Date().toISOString()}
107
- ---
108
-
109
- `;
110
- fs.writeFileSync(filePath, frontmatter + text, 'utf-8');
111
- return filePath;
112
- }
113
-
114
- // --- Drill layer ---
115
-
116
- function entryToMd(entry) {
117
- return `---
118
- id: ${entry.id}
119
- title: ${entry.title}
120
- subject: ${entry.subject}
121
- tags: ${JSON.stringify(entry.tags)}
122
- source_type: ${entry.source_type}
123
- source_ref: ${entry.source_ref}
124
- parent_id: ${entry.parent_id || ''}
125
- sort_order: ${entry.sort_order !== undefined ? entry.sort_order : ''}
126
- created_date: ${entry.created_date}
127
- created_at: ${entry.created_at}
128
- ---
129
-
130
- ${entry.content}
131
- `;
132
- }
133
-
134
- function mdToEntry(content, filePath) {
135
- const match = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
136
- if (!match) return null;
137
- const meta = {};
138
- match[1].split('\n').forEach(line => {
139
- const idx = line.indexOf(':');
140
- if (idx > 0) {
141
- const key = line.slice(0, idx).trim();
142
- const val = line.slice(idx + 1).trim();
143
- meta[key] = val;
144
- }
145
- });
146
- let tags = [];
147
- try { tags = JSON.parse(meta.tags || '[]'); } catch {}
148
- return {
149
- id: meta.id,
150
- title: meta.title || '',
151
- content: match[2].trim(),
152
- subject: meta.subject || '',
153
- tags,
154
- source_type: meta.source_type || 'text',
155
- source_ref: meta.source_ref || '',
156
- parent_id: meta.parent_id || '',
157
- created_date: meta.created_date || '',
158
- created_at: meta.created_at || '',
159
- sort_order: meta.sort_order !== undefined ? parseInt(meta.sort_order, 10) : undefined,
160
- _file: filePath,
161
- };
162
- }
163
-
164
- function getDrillDir(sourceType) {
165
- return sourceType === 'qa' ? DRILL_EXT : DRILL_CORE;
166
- }
167
-
168
- function addEntry({ id: passedId, title, content, subject = '', tags = [], source_type = 'text', source_ref = '', parent_id = '' }) {
169
- const id = passedId || uuidv4();
170
- const now = new Date();
171
- const entry = {
172
- id, title, content, subject, tags, source_type, source_ref, parent_id,
173
- created_date: now.toISOString().slice(0, 10),
174
- created_at: now.toISOString(),
175
- };
176
-
177
- const dir = getDrillDir(source_type);
178
- const subDir = path.join(dir, sanitize(subject || 'unsorted'));
179
- fs.mkdirSync(subDir, { recursive: true });
180
- const fileName = `${sanitize(title)}_${id.slice(0, 8)}.md`;
181
- fs.writeFileSync(path.join(subDir, fileName), entryToMd(entry), 'utf-8');
182
-
183
- const index = getEntryIndex();
184
- index.push({ id, title, subject, source_type, file: path.relative(WIKI_ROOT, path.join(subDir, fileName)) });
185
- saveEntryIndex(index);
186
-
187
- if (tags.length > 0) addToTagIndex(id, title, subject, tags);
188
-
189
- return entry;
190
- }
191
-
192
- function addConnection(fromId, toId, relation = '') {
193
- const id = uuidv4();
194
- const conns = getConnections();
195
- const conn = { id, from_id: fromId, to_id: toId, relation, created_at: new Date().toISOString() };
196
- conns.push(conn);
197
- saveConnections(conns);
198
- return conn;
199
- }
200
-
201
- function getAllEntries() {
202
- const entries = [];
203
- for (const base of [DRILL_CORE, DRILL_EXT]) {
204
- if (!fs.existsSync(base)) continue;
205
- const walk = (dir) => {
206
- for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
207
- if (item.isDirectory()) walk(path.join(dir, item.name));
208
- else if (item.name.endsWith('.md')) {
209
- const content = fs.readFileSync(path.join(dir, item.name), 'utf-8');
210
- const entry = mdToEntry(content, path.join(dir, item.name));
211
- if (entry) entries.push(entry);
212
- }
213
- }
214
- };
215
- walk(base);
216
- }
217
- entries.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
218
- return entries;
219
- }
220
-
221
- function getAllConnections() {
222
- return getConnections();
223
- }
224
-
225
- function getEntry(id) {
226
- return getAllEntries().find(e => e.id === id) || null;
227
- }
228
-
229
- function searchEntries(query) {
230
- const q = query.toLowerCase();
231
- return getAllEntries().filter(e =>
232
- e.title.toLowerCase().includes(q) || e.content.toLowerCase().includes(q)
233
- );
234
- }
235
-
236
- function deleteEntry(id) {
237
- const entry = getEntry(id);
238
- if (entry?.tags?.length > 0) removeFromTagIndex(id, entry.tags);
239
- if (entry?._file && fs.existsSync(entry._file)) fs.unlinkSync(entry._file);
240
- const index = getEntryIndex().filter(e => e.id !== id);
241
- saveEntryIndex(index);
242
- const conns = getConnections().filter(c => c.from_id !== id && c.to_id !== id);
243
- saveConnections(conns);
244
- }
245
-
246
- function updateEntry(id, data) {
247
- const entry = getEntry(id);
248
- if (!entry) return null;
249
- const oldTags = entry.tags || [];
250
- const updated = { ...entry };
251
- if (data.title !== undefined) updated.title = data.title;
252
- if (data.content !== undefined) updated.content = data.content;
253
- if (data.subject !== undefined) updated.subject = data.subject;
254
- if (data.tags !== undefined) updated.tags = data.tags;
255
- if (data.sort_order !== undefined) updated.sort_order = data.sort_order;
256
- delete updated._file;
257
- if (entry._file && fs.existsSync(entry._file)) {
258
- fs.writeFileSync(entry._file, entryToMd(updated), 'utf-8');
259
- }
260
- const index = getEntryIndex();
261
- const idx = index.findIndex(e => e.id === id);
262
- if (idx >= 0) {
263
- if (data.title !== undefined) index[idx].title = data.title;
264
- if (data.subject !== undefined) index[idx].subject = data.subject;
265
- }
266
- saveEntryIndex(index);
267
-
268
- const newTags = updated.tags || [];
269
- const removedTags = oldTags.filter(t => !newTags.includes(t));
270
- const addedTags = newTags.filter(t => !oldTags.includes(t));
271
- if (removedTags.length > 0) removeFromTagIndex(id, removedTags);
272
- if (addedTags.length > 0) addToTagIndex(id, updated.title, updated.subject, addedTags);
273
-
274
- return updated;
275
- }
276
-
277
- // --- Topic pages ---
278
-
279
- function getTopicDir(entryId) {
280
- const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
281
- fs.mkdirSync(dir, { recursive: true });
282
- return dir;
283
- }
284
-
285
- function saveTopicPage(entryId, html, qaHistory = [], comments = [], includedQaIds = []) {
286
- const dir = getTopicDir(entryId);
287
- const existing = getTopicPages(entryId);
288
- const version = existing.length > 0 ? existing[0].version + 1 : 1;
289
- const id = uuidv4();
290
- const now = new Date().toISOString();
291
-
292
- const meta = { id, entry_id: entryId, version, comments, qa_history: qaHistory, included_qa_ids: includedQaIds, created_at: now };
293
- const content = `---\n${JSON.stringify(meta, null, 2)}\n---\n\n${html}`;
294
- fs.writeFileSync(path.join(dir, `v${version}.md`), content, 'utf-8');
295
-
296
- return { id, entry_id: entryId, version, html, comments, qa_history: qaHistory, created_at: now };
297
- }
298
-
299
- function getTopicPages(entryId) {
300
- const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
301
- if (!fs.existsSync(dir)) return [];
302
- const pages = [];
303
- for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort().reverse()) {
304
- try {
305
- const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
306
- const match = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
307
- if (!match) continue;
308
- const meta = JSON.parse(match[1]);
309
- if (meta.entry_id !== entryId) continue;
310
- pages.push({ ...meta, html: match[2] });
311
- } catch {}
312
- }
313
- return pages.sort((a, b) => b.version - a.version);
314
- }
315
-
316
- function getLatestTopicPage(entryId) {
317
- const pages = getTopicPages(entryId);
318
- return pages.length > 0 ? pages[0] : null;
319
- }
320
-
321
- function updateTopicPageField(pageId, field, value) {
322
- if (!fs.existsSync(TOPICS_DIR)) return;
323
- for (const dir of fs.readdirSync(TOPICS_DIR)) {
324
- const dirPath = path.join(TOPICS_DIR, dir);
325
- if (!fs.statSync(dirPath).isDirectory()) continue;
326
- for (const f of fs.readdirSync(dirPath).filter(f => f.endsWith('.md'))) {
327
- const filePath = path.join(dirPath, f);
328
- try {
329
- const raw = fs.readFileSync(filePath, 'utf-8');
330
- const match = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
331
- if (!match) continue;
332
- const meta = JSON.parse(match[1]);
333
- if (meta.id === pageId) {
334
- meta[field] = value;
335
- fs.writeFileSync(filePath, `---\n${JSON.stringify(meta, null, 2)}\n---\n\n${match[2]}`, 'utf-8');
336
- return;
337
- }
338
- } catch {}
339
- }
340
- }
341
- }
342
-
343
- function updateTopicPageComments(pageId, comments) {
344
- updateTopicPageField(pageId, 'comments', comments);
345
- }
346
-
347
- function updateTopicPageQaHistory(pageId, qaHistory) {
348
- updateTopicPageField(pageId, 'qa_history', qaHistory);
349
- }
350
-
351
- function deleteTopicPageVersion(entryId, version) {
352
- const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
353
- const filePath = path.join(dir, `v${version}.md`);
354
- if (!fs.existsSync(filePath)) return false;
355
- fs.unlinkSync(filePath);
356
- return true;
357
- }
358
-
359
- function getTopicPageByVersion(entryId, version) {
360
- const pages = getTopicPages(entryId);
361
- return pages.find(p => p.version === version) || null;
362
- }
363
-
364
- function getTopicPageStatusMap() {
365
- if (!fs.existsSync(TOPICS_DIR)) return {};
366
- const result = {};
367
- for (const dir of fs.readdirSync(TOPICS_DIR)) {
368
- const dirPath = path.join(TOPICS_DIR, dir);
369
- if (!fs.statSync(dirPath).isDirectory()) continue;
370
- for (const f of fs.readdirSync(dirPath).filter(f => f.endsWith('.md')).sort().reverse()) {
371
- try {
372
- const raw = fs.readFileSync(path.join(dirPath, f), 'utf-8');
373
- const match = raw.match(/^---\n([\s\S]*?)\n---\n/);
374
- if (!match) continue;
375
- const meta = JSON.parse(match[1]);
376
- const entryId = meta.entry_id;
377
- if (!entryId || result[entryId]) continue;
378
- const hasQa = Array.isArray(meta.qa_history) && meta.qa_history.some(q => q.answer);
379
- result[entryId] = { has_topic_page: true, has_qa: hasQa };
380
- } catch {}
381
- }
382
- }
383
- return result;
384
- }
385
-
386
- function getChildren(parentId) {
387
- return getAllEntries().filter(e => e.parent_id === parentId).sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999));
388
- }
389
-
390
- // --- Independent QA storage ---
391
-
392
- function getEntryQA(entryId) {
393
- const file = path.join(QA_DIR, `${entryId}.json`);
394
- try {
395
- return JSON.parse(fs.readFileSync(file, 'utf-8'));
396
- } catch {
397
- // Migration: seed from latest topic page if available
398
- const tp = getLatestTopicPage(entryId);
399
- if (tp?.page?.qa_history?.length) {
400
- const qa = tp.page.qa_history.filter(h => h.answer);
401
- if (qa.length) {
402
- saveEntryQA(entryId, qa);
403
- return qa;
404
- }
405
- }
406
- return [];
407
- }
408
- }
409
-
410
- function saveEntryQA(entryId, qaHistory) {
411
- fs.writeFileSync(path.join(QA_DIR, `${entryId}.json`), JSON.stringify(qaHistory, null, 2), 'utf-8');
412
- }
413
-
414
- module.exports = { addRaw, addEntry, addConnection, getAllEntries, getAllConnections, getEntry, searchEntries, deleteEntry, updateEntry, getTagIndex, rebuildTagIndex, WIKI_ROOT, saveTopicPage, getTopicPages, getLatestTopicPage, updateTopicPageComments, updateTopicPageQaHistory, getChildren, deleteTopicPageVersion, getTopicPageByVersion, getTopicPageStatusMap, getEntryQA, saveEntryQA };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { v4: uuidv4 } = require('uuid');
4
+
5
+ const WIKI_ROOT = process.env.STUDYLENS_WIKI_DIR || path.join(__dirname, '..', 'wiki');
6
+ const RAW_DIR = path.join(WIKI_ROOT, 'raw');
7
+ const DRILL_CORE = path.join(WIKI_ROOT, 'drill', 'core');
8
+ const DRILL_EXT = path.join(WIKI_ROOT, 'drill', 'extended');
9
+ const INDEX_DIR = path.join(WIKI_ROOT, 'index');
10
+ const TAGS_DIR = path.join(INDEX_DIR, 'tags');
11
+ const TOPICS_DIR = path.join(WIKI_ROOT, 'topics');
12
+ const QA_DIR = path.join(WIKI_ROOT, 'qa');
13
+
14
+ function ensureDirs() {
15
+ for (const d of [RAW_DIR, DRILL_CORE, DRILL_EXT, INDEX_DIR, TAGS_DIR, TOPICS_DIR, QA_DIR]) {
16
+ fs.mkdirSync(d, { recursive: true });
17
+ }
18
+ }
19
+ ensureDirs();
20
+
21
+ function sanitize(name) {
22
+ return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 60);
23
+ }
24
+
25
+ function readJson(filePath, fallback) {
26
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return fallback; }
27
+ }
28
+
29
+ function writeJson(filePath, data) {
30
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
31
+ }
32
+
33
+ // --- Index management ---
34
+
35
+ function getConnections() {
36
+ return readJson(path.join(INDEX_DIR, 'connections.json'), []);
37
+ }
38
+
39
+ function saveConnections(conns) {
40
+ writeJson(path.join(INDEX_DIR, 'connections.json'), conns);
41
+ }
42
+
43
+ function getEntryIndex() {
44
+ return readJson(path.join(INDEX_DIR, 'entries.json'), []);
45
+ }
46
+
47
+ function saveEntryIndex(entries) {
48
+ writeJson(path.join(INDEX_DIR, 'entries.json'), entries);
49
+ }
50
+
51
+ // --- Tag index ---
52
+
53
+ function getTagIndex(tag) {
54
+ return readJson(path.join(TAGS_DIR, `${sanitize(tag)}.json`), []);
55
+ }
56
+
57
+ function saveTagIndex(tag, entries) {
58
+ writeJson(path.join(TAGS_DIR, `${sanitize(tag)}.json`), entries);
59
+ }
60
+
61
+ function addToTagIndex(entryId, title, subject, tags) {
62
+ for (const tag of tags) {
63
+ const idx = getTagIndex(tag);
64
+ if (!idx.some(e => e.id === entryId)) {
65
+ idx.push({ id: entryId, title, subject });
66
+ saveTagIndex(tag, idx);
67
+ }
68
+ }
69
+ }
70
+
71
+ function removeFromTagIndex(entryId, tags) {
72
+ for (const tag of tags) {
73
+ const idx = getTagIndex(tag).filter(e => e.id !== entryId);
74
+ saveTagIndex(tag, idx);
75
+ }
76
+ }
77
+
78
+ function rebuildTagIndex() {
79
+ const entries = getAllEntries();
80
+ const tagMap = {};
81
+ for (const e of entries) {
82
+ for (const tag of (e.tags || [])) {
83
+ if (!tagMap[tag]) tagMap[tag] = [];
84
+ tagMap[tag].push({ id: e.id, title: e.title, subject: e.subject });
85
+ }
86
+ }
87
+ // Clear old tag files
88
+ if (fs.existsSync(TAGS_DIR)) {
89
+ for (const f of fs.readdirSync(TAGS_DIR)) fs.unlinkSync(path.join(TAGS_DIR, f));
90
+ }
91
+ for (const [tag, entries] of Object.entries(tagMap)) {
92
+ saveTagIndex(tag, entries);
93
+ }
94
+ return Object.keys(tagMap).length;
95
+ }
96
+
97
+ // --- Raw layer ---
98
+
99
+ function addRaw(text, sourceType, sourceRef) {
100
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
101
+ const name = `${ts}_${sanitize(sourceRef || sourceType)}`;
102
+ const filePath = path.join(RAW_DIR, `${name}.md`);
103
+ const frontmatter = `---
104
+ source_type: ${sourceType}
105
+ source_ref: ${sourceRef}
106
+ created_at: ${new Date().toISOString()}
107
+ ---
108
+
109
+ `;
110
+ fs.writeFileSync(filePath, frontmatter + text, 'utf-8');
111
+ return filePath;
112
+ }
113
+
114
+ // --- Drill layer ---
115
+
116
+ function entryToMd(entry) {
117
+ return `---
118
+ id: ${entry.id}
119
+ title: ${entry.title}
120
+ subject: ${entry.subject}
121
+ tags: ${JSON.stringify(entry.tags)}
122
+ source_type: ${entry.source_type}
123
+ source_ref: ${entry.source_ref}
124
+ parent_id: ${entry.parent_id || ''}
125
+ sort_order: ${entry.sort_order !== undefined ? entry.sort_order : ''}
126
+ created_date: ${entry.created_date}
127
+ created_at: ${entry.created_at}
128
+ ---
129
+
130
+ ${entry.content}
131
+ `;
132
+ }
133
+
134
+ function mdToEntry(content, filePath) {
135
+ const match = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
136
+ if (!match) return null;
137
+ const meta = {};
138
+ match[1].split('\n').forEach(line => {
139
+ const idx = line.indexOf(':');
140
+ if (idx > 0) {
141
+ const key = line.slice(0, idx).trim();
142
+ const val = line.slice(idx + 1).trim();
143
+ meta[key] = val;
144
+ }
145
+ });
146
+ let tags = [];
147
+ try { tags = JSON.parse(meta.tags || '[]'); } catch {}
148
+ return {
149
+ id: meta.id,
150
+ title: meta.title || '',
151
+ content: match[2].trim(),
152
+ subject: meta.subject || '',
153
+ tags,
154
+ source_type: meta.source_type || 'text',
155
+ source_ref: meta.source_ref || '',
156
+ parent_id: meta.parent_id || '',
157
+ created_date: meta.created_date || '',
158
+ created_at: meta.created_at || '',
159
+ sort_order: meta.sort_order !== undefined ? parseInt(meta.sort_order, 10) : undefined,
160
+ _file: filePath,
161
+ };
162
+ }
163
+
164
+ function getDrillDir(sourceType) {
165
+ return sourceType === 'qa' ? DRILL_EXT : DRILL_CORE;
166
+ }
167
+
168
+ function addEntry({ id: passedId, title, content, subject = '', tags = [], source_type = 'text', source_ref = '', parent_id = '' }) {
169
+ const id = passedId || uuidv4();
170
+ const now = new Date();
171
+ const entry = {
172
+ id, title, content, subject, tags, source_type, source_ref, parent_id,
173
+ created_date: now.toISOString().slice(0, 10),
174
+ created_at: now.toISOString(),
175
+ };
176
+
177
+ const dir = getDrillDir(source_type);
178
+ const subDir = path.join(dir, sanitize(subject || 'unsorted'));
179
+ fs.mkdirSync(subDir, { recursive: true });
180
+ const fileName = `${sanitize(title)}_${id.slice(0, 8)}.md`;
181
+ fs.writeFileSync(path.join(subDir, fileName), entryToMd(entry), 'utf-8');
182
+
183
+ const index = getEntryIndex();
184
+ index.push({ id, title, subject, source_type, file: path.relative(WIKI_ROOT, path.join(subDir, fileName)) });
185
+ saveEntryIndex(index);
186
+
187
+ if (tags.length > 0) addToTagIndex(id, title, subject, tags);
188
+
189
+ return entry;
190
+ }
191
+
192
+ function addConnection(fromId, toId, relation = '') {
193
+ const id = uuidv4();
194
+ const conns = getConnections();
195
+ const conn = { id, from_id: fromId, to_id: toId, relation, created_at: new Date().toISOString() };
196
+ conns.push(conn);
197
+ saveConnections(conns);
198
+ return conn;
199
+ }
200
+
201
+ function getAllEntries() {
202
+ const entries = [];
203
+ for (const base of [DRILL_CORE, DRILL_EXT]) {
204
+ if (!fs.existsSync(base)) continue;
205
+ const walk = (dir) => {
206
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
207
+ if (item.isDirectory()) walk(path.join(dir, item.name));
208
+ else if (item.name.endsWith('.md')) {
209
+ const content = fs.readFileSync(path.join(dir, item.name), 'utf-8');
210
+ const entry = mdToEntry(content, path.join(dir, item.name));
211
+ if (entry) entries.push(entry);
212
+ }
213
+ }
214
+ };
215
+ walk(base);
216
+ }
217
+ entries.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
218
+ return entries;
219
+ }
220
+
221
+ function getAllConnections() {
222
+ return getConnections();
223
+ }
224
+
225
+ function getEntry(id) {
226
+ return getAllEntries().find(e => e.id === id) || null;
227
+ }
228
+
229
+ function searchEntries(query) {
230
+ const q = query.toLowerCase();
231
+ return getAllEntries().filter(e =>
232
+ e.title.toLowerCase().includes(q) || e.content.toLowerCase().includes(q)
233
+ );
234
+ }
235
+
236
+ function deleteEntry(id) {
237
+ const entry = getEntry(id);
238
+ if (entry?.tags?.length > 0) removeFromTagIndex(id, entry.tags);
239
+ if (entry?._file && fs.existsSync(entry._file)) fs.unlinkSync(entry._file);
240
+ const index = getEntryIndex().filter(e => e.id !== id);
241
+ saveEntryIndex(index);
242
+ const conns = getConnections().filter(c => c.from_id !== id && c.to_id !== id);
243
+ saveConnections(conns);
244
+ }
245
+
246
+ function updateEntry(id, data) {
247
+ const entry = getEntry(id);
248
+ if (!entry) return null;
249
+ const oldTags = entry.tags || [];
250
+ const updated = { ...entry };
251
+ if (data.title !== undefined) updated.title = data.title;
252
+ if (data.content !== undefined) updated.content = data.content;
253
+ if (data.subject !== undefined) updated.subject = data.subject;
254
+ if (data.tags !== undefined) updated.tags = data.tags;
255
+ if (data.sort_order !== undefined) updated.sort_order = data.sort_order;
256
+ delete updated._file;
257
+ if (entry._file && fs.existsSync(entry._file)) {
258
+ fs.writeFileSync(entry._file, entryToMd(updated), 'utf-8');
259
+ }
260
+ const index = getEntryIndex();
261
+ const idx = index.findIndex(e => e.id === id);
262
+ if (idx >= 0) {
263
+ if (data.title !== undefined) index[idx].title = data.title;
264
+ if (data.subject !== undefined) index[idx].subject = data.subject;
265
+ }
266
+ saveEntryIndex(index);
267
+
268
+ const newTags = updated.tags || [];
269
+ const removedTags = oldTags.filter(t => !newTags.includes(t));
270
+ const addedTags = newTags.filter(t => !oldTags.includes(t));
271
+ if (removedTags.length > 0) removeFromTagIndex(id, removedTags);
272
+ if (addedTags.length > 0) addToTagIndex(id, updated.title, updated.subject, addedTags);
273
+
274
+ return updated;
275
+ }
276
+
277
+ // --- Topic pages ---
278
+
279
+ function getTopicDir(entryId) {
280
+ const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
281
+ fs.mkdirSync(dir, { recursive: true });
282
+ return dir;
283
+ }
284
+
285
+ function saveTopicPage(entryId, html, qaHistory = [], comments = [], includedQaIds = []) {
286
+ const dir = getTopicDir(entryId);
287
+ const existing = getTopicPages(entryId);
288
+ const version = existing.length > 0 ? existing[0].version + 1 : 1;
289
+ const id = uuidv4();
290
+ const now = new Date().toISOString();
291
+
292
+ const meta = { id, entry_id: entryId, version, comments, qa_history: qaHistory, included_qa_ids: includedQaIds, created_at: now };
293
+ const content = `---\n${JSON.stringify(meta, null, 2)}\n---\n\n${html}`;
294
+ fs.writeFileSync(path.join(dir, `v${version}.md`), content, 'utf-8');
295
+
296
+ return { id, entry_id: entryId, version, html, comments, qa_history: qaHistory, created_at: now };
297
+ }
298
+
299
+ function getTopicPages(entryId) {
300
+ const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
301
+ if (!fs.existsSync(dir)) return [];
302
+ const pages = [];
303
+ for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort().reverse()) {
304
+ try {
305
+ const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
306
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
307
+ if (!match) continue;
308
+ const meta = JSON.parse(match[1]);
309
+ if (meta.entry_id !== entryId) continue;
310
+ pages.push({ ...meta, html: match[2] });
311
+ } catch {}
312
+ }
313
+ return pages.sort((a, b) => b.version - a.version);
314
+ }
315
+
316
+ function getLatestTopicPage(entryId) {
317
+ const pages = getTopicPages(entryId);
318
+ return pages.length > 0 ? pages[0] : null;
319
+ }
320
+
321
+ function updateTopicPageField(pageId, field, value) {
322
+ if (!fs.existsSync(TOPICS_DIR)) return;
323
+ for (const dir of fs.readdirSync(TOPICS_DIR)) {
324
+ const dirPath = path.join(TOPICS_DIR, dir);
325
+ if (!fs.statSync(dirPath).isDirectory()) continue;
326
+ for (const f of fs.readdirSync(dirPath).filter(f => f.endsWith('.md'))) {
327
+ const filePath = path.join(dirPath, f);
328
+ try {
329
+ const raw = fs.readFileSync(filePath, 'utf-8');
330
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
331
+ if (!match) continue;
332
+ const meta = JSON.parse(match[1]);
333
+ if (meta.id === pageId) {
334
+ meta[field] = value;
335
+ fs.writeFileSync(filePath, `---\n${JSON.stringify(meta, null, 2)}\n---\n\n${match[2]}`, 'utf-8');
336
+ return;
337
+ }
338
+ } catch {}
339
+ }
340
+ }
341
+ }
342
+
343
+ function updateTopicPageComments(pageId, comments) {
344
+ updateTopicPageField(pageId, 'comments', comments);
345
+ }
346
+
347
+ function updateTopicPageQaHistory(pageId, qaHistory) {
348
+ updateTopicPageField(pageId, 'qa_history', qaHistory);
349
+ }
350
+
351
+ function deleteTopicPageVersion(entryId, version) {
352
+ const dir = path.join(TOPICS_DIR, entryId.slice(0, 8));
353
+ const filePath = path.join(dir, `v${version}.md`);
354
+ if (!fs.existsSync(filePath)) return false;
355
+ fs.unlinkSync(filePath);
356
+ return true;
357
+ }
358
+
359
+ function getTopicPageByVersion(entryId, version) {
360
+ const pages = getTopicPages(entryId);
361
+ return pages.find(p => p.version === version) || null;
362
+ }
363
+
364
+ function getTopicPageStatusMap() {
365
+ if (!fs.existsSync(TOPICS_DIR)) return {};
366
+ const result = {};
367
+ for (const dir of fs.readdirSync(TOPICS_DIR)) {
368
+ const dirPath = path.join(TOPICS_DIR, dir);
369
+ if (!fs.statSync(dirPath).isDirectory()) continue;
370
+ for (const f of fs.readdirSync(dirPath).filter(f => f.endsWith('.md')).sort().reverse()) {
371
+ try {
372
+ const raw = fs.readFileSync(path.join(dirPath, f), 'utf-8');
373
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n/);
374
+ if (!match) continue;
375
+ const meta = JSON.parse(match[1]);
376
+ const entryId = meta.entry_id;
377
+ if (!entryId || result[entryId]) continue;
378
+ const hasQa = Array.isArray(meta.qa_history) && meta.qa_history.some(q => q.answer);
379
+ result[entryId] = { has_topic_page: true, has_qa: hasQa };
380
+ } catch {}
381
+ }
382
+ }
383
+ return result;
384
+ }
385
+
386
+ function getChildren(parentId) {
387
+ return getAllEntries().filter(e => e.parent_id === parentId).sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999));
388
+ }
389
+
390
+ // --- Independent QA storage ---
391
+
392
+ function getEntryQA(entryId) {
393
+ const file = path.join(QA_DIR, `${entryId}.json`);
394
+ try {
395
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
396
+ } catch {
397
+ // Migration: seed from latest topic page if available
398
+ const tp = getLatestTopicPage(entryId);
399
+ if (tp?.page?.qa_history?.length) {
400
+ const qa = tp.page.qa_history.filter(h => h.answer);
401
+ if (qa.length) {
402
+ saveEntryQA(entryId, qa);
403
+ return qa;
404
+ }
405
+ }
406
+ return [];
407
+ }
408
+ }
409
+
410
+ function saveEntryQA(entryId, qaHistory) {
411
+ fs.writeFileSync(path.join(QA_DIR, `${entryId}.json`), JSON.stringify(qaHistory, null, 2), 'utf-8');
412
+ }
413
+
414
+ module.exports = { addRaw, addEntry, addConnection, getAllEntries, getAllConnections, getEntry, searchEntries, deleteEntry, updateEntry, getTagIndex, rebuildTagIndex, WIKI_ROOT, saveTopicPage, getTopicPages, getLatestTopicPage, updateTopicPageComments, updateTopicPageQaHistory, getChildren, deleteTopicPageVersion, getTopicPageByVersion, getTopicPageStatusMap, getEntryQA, saveEntryQA };