dictawhisper 0.0.13 → 0.0.14
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 +10 -2
- package/bin/dictawhisper.mjs +14 -0
- package/config.example.json +41 -0
- package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
- package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
- package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
- package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
- package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
- package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
- package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
- package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
- package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
- package/dist/ui/_app/version.json +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/favicon.svg +3 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +12 -3
- package/scripts/transcribe_faster_whisper.py +428 -0
- package/src/apiRoutes.ts +464 -0
- package/src/classes/Queue.ts +77 -0
- package/src/classes/Watcher.ts +117 -0
- package/src/cli.ts +132 -0
- package/src/config.ts +238 -0
- package/src/dev.ts +63 -0
- package/src/doctor.ts +12 -0
- package/src/lib/alignLib.ts +154 -0
- package/src/lib/audioLib.ts +202 -0
- package/src/lib/audioProbeLib.ts +112 -0
- package/src/lib/cleanupProvenanceLib.ts +80 -0
- package/src/lib/downloadLib.ts +8 -0
- package/src/lib/fileSettleLib.ts +213 -0
- package/src/lib/fsLib.ts +37 -0
- package/src/lib/healthLib.ts +377 -0
- package/src/lib/inboxUrl.ts +123 -0
- package/src/lib/journalEmbedLib.ts +127 -0
- package/src/lib/journalIndexLib.ts +923 -0
- package/src/lib/journalQueryLib.ts +269 -0
- package/src/lib/journalService.ts +287 -0
- package/src/lib/jsonLib.ts +71 -0
- package/src/lib/localberthGet.ts +14 -0
- package/src/lib/ollanetLib.ts +117 -0
- package/src/lib/ollanetReadyLib.ts +7 -0
- package/src/lib/organizationLib.ts +182 -0
- package/src/lib/pathAllowLib.ts +60 -0
- package/src/lib/queueLib.ts +30 -0
- package/src/lib/speechCleanupLib.ts +14 -0
- package/src/lib/tagConsolidateLib.ts +431 -0
- package/src/lib/tailscaleLib.ts +78 -0
- package/src/lib/transcriptionLib.ts +484 -0
- package/src/lib/whisperLib.ts +409 -0
- package/src/mcp.ts +151 -0
- package/src/prompts/cleanTranscription.ts +62 -0
- package/src/prompts/consolidateTags.ts +38 -0
- package/src/prompts/whisperInitial.ts +11 -0
- package/src/server.ts +220 -0
- package/src/socketEvents.ts +72 -0
- package/src/types/transcription.ts +56 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { getLoadablePath } from 'sqlite-vec';
|
|
6
|
+
import { isSkippedWatchPath } from './fileSettleLib.ts';
|
|
7
|
+
import { dayOf } from './journalQueryLib.ts';
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = '2';
|
|
10
|
+
const PREVIEW_LIMIT = 200;
|
|
11
|
+
|
|
12
|
+
export type SearchMode = 'lex' | 'semantic' | 'hybrid';
|
|
13
|
+
export type SearchSort = 'recent' | 'oldest' | 'relevance';
|
|
14
|
+
|
|
15
|
+
export type IndexSearchHit = {
|
|
16
|
+
jsonFile: string;
|
|
17
|
+
basename: string;
|
|
18
|
+
day: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
preview: string;
|
|
21
|
+
snippet?: string;
|
|
22
|
+
cue?: number | null;
|
|
23
|
+
score: number;
|
|
24
|
+
hasCleaned: boolean;
|
|
25
|
+
audioError: string | null;
|
|
26
|
+
starred: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type IndexSummary = {
|
|
30
|
+
jsonFile: string;
|
|
31
|
+
basename: string;
|
|
32
|
+
day: string;
|
|
33
|
+
year: string;
|
|
34
|
+
month: string;
|
|
35
|
+
folder: string;
|
|
36
|
+
tags: string[];
|
|
37
|
+
preview: string;
|
|
38
|
+
hasCleaned: boolean;
|
|
39
|
+
audioError: string | null;
|
|
40
|
+
starred: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type IndexStats = {
|
|
44
|
+
path: string;
|
|
45
|
+
notes: number;
|
|
46
|
+
unreadable: number;
|
|
47
|
+
starred: number;
|
|
48
|
+
embedded: number;
|
|
49
|
+
embedModel: string | null;
|
|
50
|
+
embedDim: number | null;
|
|
51
|
+
lastRebuild: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type IndexListOptions = {
|
|
55
|
+
year?: string;
|
|
56
|
+
month?: string;
|
|
57
|
+
unreadable?: boolean;
|
|
58
|
+
starred?: boolean;
|
|
59
|
+
all?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type NoteRow = {
|
|
63
|
+
json_file: string;
|
|
64
|
+
basename: string;
|
|
65
|
+
day: string;
|
|
66
|
+
year: string;
|
|
67
|
+
month: string;
|
|
68
|
+
folder: string;
|
|
69
|
+
tags: string;
|
|
70
|
+
preview: string;
|
|
71
|
+
has_cleaned: number;
|
|
72
|
+
audio_error: string | null;
|
|
73
|
+
starred: number;
|
|
74
|
+
mtime_ms: number;
|
|
75
|
+
text_hash: string;
|
|
76
|
+
body: string;
|
|
77
|
+
raw: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function rowId(value: number | bigint | string): number {
|
|
81
|
+
return Number(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** sqlite-vec rejects REAL-bound rowids; node:sqlite sends JS numbers as REAL. */
|
|
85
|
+
function vecRowId(value: number | bigint | string): bigint {
|
|
86
|
+
return BigInt(rowId(value));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class JournalIndex {
|
|
90
|
+
readonly dbPath: string;
|
|
91
|
+
private db: DatabaseSync;
|
|
92
|
+
|
|
93
|
+
constructor(dbPath: string) {
|
|
94
|
+
this.dbPath = path.resolve(dbPath);
|
|
95
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
96
|
+
this.db = new DatabaseSync(this.dbPath, { allowExtension: true });
|
|
97
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
98
|
+
this.db.exec('PRAGMA busy_timeout = 8000');
|
|
99
|
+
this.db.exec('PRAGMA foreign_keys = ON');
|
|
100
|
+
this.db.enableLoadExtension(true);
|
|
101
|
+
this.db.loadExtension(getLoadablePath());
|
|
102
|
+
this.migrate();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
close() {
|
|
106
|
+
this.db.close();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private migrate() {
|
|
110
|
+
this.db.exec(`
|
|
111
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
112
|
+
key TEXT PRIMARY KEY,
|
|
113
|
+
value TEXT NOT NULL
|
|
114
|
+
);
|
|
115
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
116
|
+
json_file TEXT PRIMARY KEY,
|
|
117
|
+
basename TEXT NOT NULL,
|
|
118
|
+
day TEXT NOT NULL,
|
|
119
|
+
year TEXT NOT NULL,
|
|
120
|
+
month TEXT NOT NULL,
|
|
121
|
+
folder TEXT NOT NULL,
|
|
122
|
+
tags TEXT NOT NULL,
|
|
123
|
+
preview TEXT NOT NULL,
|
|
124
|
+
has_cleaned INTEGER NOT NULL,
|
|
125
|
+
audio_error TEXT,
|
|
126
|
+
mtime_ms INTEGER NOT NULL,
|
|
127
|
+
text_hash TEXT NOT NULL,
|
|
128
|
+
body TEXT NOT NULL,
|
|
129
|
+
raw TEXT NOT NULL
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS notes_day ON notes(day);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS notes_year ON notes(year, month);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS notes_folder ON notes(folder);
|
|
134
|
+
`);
|
|
135
|
+
const noteCols = (
|
|
136
|
+
this.db.prepare('PRAGMA table_info(notes)').all() as { name: string }[]
|
|
137
|
+
).map((row) => row.name);
|
|
138
|
+
if (!noteCols.includes('starred')) {
|
|
139
|
+
this.db.exec('ALTER TABLE notes ADD COLUMN starred INTEGER NOT NULL DEFAULT 0');
|
|
140
|
+
}
|
|
141
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS notes_starred ON notes(starred)');
|
|
142
|
+
this.db.exec(`
|
|
143
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
144
|
+
basename, tags, body, raw,
|
|
145
|
+
content='notes',
|
|
146
|
+
content_rowid='rowid',
|
|
147
|
+
tokenize='unicode61'
|
|
148
|
+
);
|
|
149
|
+
`);
|
|
150
|
+
this.db.exec(`
|
|
151
|
+
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
|
152
|
+
INSERT INTO notes_fts(rowid, basename, tags, body, raw)
|
|
153
|
+
VALUES (new.rowid, new.basename, new.tags, new.body, new.raw);
|
|
154
|
+
END;
|
|
155
|
+
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
|
|
156
|
+
INSERT INTO notes_fts(notes_fts, rowid, basename, tags, body, raw)
|
|
157
|
+
VALUES ('delete', old.rowid, old.basename, old.tags, old.body, old.raw);
|
|
158
|
+
END;
|
|
159
|
+
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|
160
|
+
INSERT INTO notes_fts(notes_fts, rowid, basename, tags, body, raw)
|
|
161
|
+
VALUES ('delete', old.rowid, old.basename, old.tags, old.body, old.raw);
|
|
162
|
+
INSERT INTO notes_fts(rowid, basename, tags, body, raw)
|
|
163
|
+
VALUES (new.rowid, new.basename, new.tags, new.body, new.raw);
|
|
164
|
+
END;
|
|
165
|
+
`);
|
|
166
|
+
const version = this.meta('schema_version');
|
|
167
|
+
if (version !== SCHEMA_VERSION) this.setMeta('schema_version', SCHEMA_VERSION);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
meta(key: string): string | null {
|
|
171
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as
|
|
172
|
+
| { value: string }
|
|
173
|
+
| undefined;
|
|
174
|
+
return row?.value ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setMeta(key: string, value: string) {
|
|
178
|
+
this.db.prepare('INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(
|
|
179
|
+
key,
|
|
180
|
+
value,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
stats(): IndexStats {
|
|
185
|
+
const notes = Number((this.db.prepare('SELECT COUNT(*) AS n FROM notes').get() as { n: number }).n);
|
|
186
|
+
const unreadable = Number(
|
|
187
|
+
(this.db.prepare("SELECT COUNT(*) AS n FROM notes WHERE audio_error IS NOT NULL AND audio_error != ''").get() as { n: number })
|
|
188
|
+
.n,
|
|
189
|
+
);
|
|
190
|
+
const starred = Number(
|
|
191
|
+
(this.db.prepare('SELECT COUNT(*) AS n FROM notes WHERE starred = 1').get() as { n: number }).n,
|
|
192
|
+
);
|
|
193
|
+
let embedded = 0;
|
|
194
|
+
if (this.hasVec()) {
|
|
195
|
+
embedded = Number((this.db.prepare('SELECT COUNT(*) AS n FROM notes_vec').get() as { n: number }).n);
|
|
196
|
+
}
|
|
197
|
+
const dimRaw = this.meta('embed_dim');
|
|
198
|
+
return {
|
|
199
|
+
path: this.dbPath,
|
|
200
|
+
notes,
|
|
201
|
+
unreadable,
|
|
202
|
+
starred,
|
|
203
|
+
embedded,
|
|
204
|
+
embedModel: this.meta('embed_model'),
|
|
205
|
+
embedDim: dimRaw ? Number(dimRaw) : null,
|
|
206
|
+
lastRebuild: this.meta('last_rebuild'),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
hasVec(): boolean {
|
|
211
|
+
const row = this.db
|
|
212
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes_vec'")
|
|
213
|
+
.get() as { name: string } | undefined;
|
|
214
|
+
return Boolean(row);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ensureVec(dim: number, model: string) {
|
|
218
|
+
const current = this.meta('embed_dim');
|
|
219
|
+
const currentModel = this.meta('embed_model');
|
|
220
|
+
if (this.hasVec() && current === String(dim) && currentModel === model) return;
|
|
221
|
+
this.db.exec('DROP TABLE IF EXISTS notes_vec');
|
|
222
|
+
this.db.exec(`CREATE VIRTUAL TABLE notes_vec USING vec0(embedding float[${dim}])`);
|
|
223
|
+
this.setMeta('embed_dim', String(dim));
|
|
224
|
+
this.setMeta('embed_model', model);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
upsertSidecar(jsonFile: string): { rowid: number; changed: boolean; skipEmbed: boolean } | null {
|
|
228
|
+
const parsed = readSidecarRow(jsonFile);
|
|
229
|
+
if (!parsed) return null;
|
|
230
|
+
const existing = this.db.prepare('SELECT rowid, text_hash, mtime_ms FROM notes WHERE json_file = ?').get(
|
|
231
|
+
parsed.json_file,
|
|
232
|
+
) as { rowid: number; text_hash: string; mtime_ms: number } | undefined;
|
|
233
|
+
if (existing && existing.text_hash === parsed.text_hash && existing.mtime_ms === parsed.mtime_ms) {
|
|
234
|
+
return { rowid: rowId(existing.rowid), changed: false, skipEmbed: Boolean(parsed.audio_error) };
|
|
235
|
+
}
|
|
236
|
+
if (existing) {
|
|
237
|
+
this.db
|
|
238
|
+
.prepare(
|
|
239
|
+
`UPDATE notes SET basename=?, day=?, year=?, month=?, folder=?, tags=?, preview=?,
|
|
240
|
+
has_cleaned=?, audio_error=?, starred=?, mtime_ms=?, text_hash=?, body=?, raw=? WHERE json_file=?`,
|
|
241
|
+
)
|
|
242
|
+
.run(
|
|
243
|
+
parsed.basename,
|
|
244
|
+
parsed.day,
|
|
245
|
+
parsed.year,
|
|
246
|
+
parsed.month,
|
|
247
|
+
parsed.folder,
|
|
248
|
+
parsed.tags,
|
|
249
|
+
parsed.preview,
|
|
250
|
+
parsed.has_cleaned,
|
|
251
|
+
parsed.audio_error,
|
|
252
|
+
parsed.starred,
|
|
253
|
+
parsed.mtime_ms,
|
|
254
|
+
parsed.text_hash,
|
|
255
|
+
parsed.body,
|
|
256
|
+
parsed.raw,
|
|
257
|
+
parsed.json_file,
|
|
258
|
+
);
|
|
259
|
+
if (this.hasVec() && existing.text_hash !== parsed.text_hash) {
|
|
260
|
+
try {
|
|
261
|
+
this.db.prepare('DELETE FROM notes_vec WHERE rowid = ?').run(vecRowId(existing.rowid));
|
|
262
|
+
} catch {
|
|
263
|
+
// vec table may be empty
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { rowid: rowId(existing.rowid), changed: true, skipEmbed: Boolean(parsed.audio_error) || !parsed.body };
|
|
267
|
+
}
|
|
268
|
+
this.db
|
|
269
|
+
.prepare(
|
|
270
|
+
`INSERT INTO notes (json_file, basename, day, year, month, folder, tags, preview, has_cleaned,
|
|
271
|
+
audio_error, starred, mtime_ms, text_hash, body, raw)
|
|
272
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
273
|
+
)
|
|
274
|
+
.run(
|
|
275
|
+
parsed.json_file,
|
|
276
|
+
parsed.basename,
|
|
277
|
+
parsed.day,
|
|
278
|
+
parsed.year,
|
|
279
|
+
parsed.month,
|
|
280
|
+
parsed.folder,
|
|
281
|
+
parsed.tags,
|
|
282
|
+
parsed.preview,
|
|
283
|
+
parsed.has_cleaned,
|
|
284
|
+
parsed.audio_error,
|
|
285
|
+
parsed.starred,
|
|
286
|
+
parsed.mtime_ms,
|
|
287
|
+
parsed.text_hash,
|
|
288
|
+
parsed.body,
|
|
289
|
+
parsed.raw,
|
|
290
|
+
);
|
|
291
|
+
const row = this.db.prepare('SELECT rowid FROM notes WHERE json_file = ?').get(parsed.json_file) as {
|
|
292
|
+
rowid: number;
|
|
293
|
+
};
|
|
294
|
+
return { rowid: rowId(row.rowid), changed: true, skipEmbed: Boolean(parsed.audio_error) || !parsed.body };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
removeSidecar(jsonFile: string) {
|
|
298
|
+
const key = path.resolve(jsonFile);
|
|
299
|
+
const existing = this.db.prepare('SELECT rowid FROM notes WHERE json_file = ?').get(key) as
|
|
300
|
+
| { rowid: number }
|
|
301
|
+
| undefined;
|
|
302
|
+
if (!existing) return;
|
|
303
|
+
if (this.hasVec()) {
|
|
304
|
+
try {
|
|
305
|
+
this.db.prepare('DELETE FROM notes_vec WHERE rowid = ?').run(vecRowId(existing.rowid));
|
|
306
|
+
} catch {
|
|
307
|
+
// ignore
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
this.db.prepare('DELETE FROM notes WHERE json_file = ?').run(key);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
putEmbedding(rowid: number, values: number[]) {
|
|
314
|
+
const json = JSON.stringify(values);
|
|
315
|
+
const id = vecRowId(rowid);
|
|
316
|
+
this.db.prepare('DELETE FROM notes_vec WHERE rowid = ?').run(id);
|
|
317
|
+
this.db.prepare('INSERT INTO notes_vec(rowid, embedding) VALUES (?, ?)').run(id, json);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
rowsNeedingEmbed(): { rowid: number; body: string; json_file: string }[] {
|
|
321
|
+
if (!this.hasVec()) {
|
|
322
|
+
return (
|
|
323
|
+
this.db
|
|
324
|
+
.prepare(
|
|
325
|
+
`SELECT rowid, body, json_file FROM notes
|
|
326
|
+
WHERE (audio_error IS NULL OR audio_error = '') AND length(body) > 0`,
|
|
327
|
+
)
|
|
328
|
+
.all() as { rowid: number | bigint; body: string; json_file: string }[]
|
|
329
|
+
).map((row) => ({ ...row, rowid: rowId(row.rowid) }));
|
|
330
|
+
}
|
|
331
|
+
return (
|
|
332
|
+
this.db
|
|
333
|
+
.prepare(
|
|
334
|
+
`SELECT n.rowid, n.body, n.json_file FROM notes n
|
|
335
|
+
LEFT JOIN notes_vec v ON v.rowid = n.rowid
|
|
336
|
+
WHERE v.rowid IS NULL AND (n.audio_error IS NULL OR n.audio_error = '') AND length(n.body) > 0`,
|
|
337
|
+
)
|
|
338
|
+
.all() as { rowid: number | bigint; body: string; json_file: string }[]
|
|
339
|
+
).map((row) => ({ ...row, rowid: rowId(row.rowid) }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
rebuildFromRoots(roots: string[]): { upserted: number; removed: number } {
|
|
343
|
+
const seen = new Set<string>();
|
|
344
|
+
let upserted = 0;
|
|
345
|
+
const walk = (dir: string) => {
|
|
346
|
+
if (!fs.existsSync(dir)) return;
|
|
347
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
348
|
+
const full = path.resolve(path.join(dir, entry.name));
|
|
349
|
+
if (entry.isDirectory()) {
|
|
350
|
+
if (isSkippedWatchPath(full)) continue;
|
|
351
|
+
walk(full);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
|
355
|
+
if (isSkippedWatchPath(full) || full.includes('_original') || full.includes('_clean')) continue;
|
|
356
|
+
const result = this.upsertSidecar(full);
|
|
357
|
+
if (result) {
|
|
358
|
+
seen.add(path.resolve(full));
|
|
359
|
+
if (result.changed) upserted += 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
for (const root of roots) walk(path.resolve(root));
|
|
364
|
+
const existing = this.db.prepare('SELECT json_file FROM notes').all() as { json_file: string }[];
|
|
365
|
+
let removed = 0;
|
|
366
|
+
for (const row of existing) {
|
|
367
|
+
if (seen.has(row.json_file)) continue;
|
|
368
|
+
this.removeSidecar(row.json_file);
|
|
369
|
+
removed += 1;
|
|
370
|
+
}
|
|
371
|
+
this.setMeta('last_rebuild', new Date().toISOString());
|
|
372
|
+
return { upserted, removed };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
years(): { year: string; count: number }[] {
|
|
376
|
+
return (
|
|
377
|
+
this.db
|
|
378
|
+
.prepare(
|
|
379
|
+
`SELECT year, COUNT(*) AS count FROM notes WHERE folder = 'journal' AND year != ''
|
|
380
|
+
GROUP BY year ORDER BY year DESC`,
|
|
381
|
+
)
|
|
382
|
+
.all() as { year: string; count: number | bigint }[]
|
|
383
|
+
).map((row) => ({ year: String(row.year), count: Number(row.count) }));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
findJsonFile(file: string): string | null {
|
|
387
|
+
const wanted = String(file || '').trim();
|
|
388
|
+
if (!wanted) return null;
|
|
389
|
+
const resolved = path.resolve(wanted);
|
|
390
|
+
const exact = this.db.prepare('SELECT json_file FROM notes WHERE json_file = ?').get(resolved) as
|
|
391
|
+
| { json_file: string }
|
|
392
|
+
| undefined;
|
|
393
|
+
if (exact) return exact.json_file;
|
|
394
|
+
const rows = this.db.prepare('SELECT json_file FROM notes WHERE basename = ? COLLATE NOCASE').all(path.basename(wanted)) as {
|
|
395
|
+
json_file: string;
|
|
396
|
+
}[];
|
|
397
|
+
return rows.length === 1 ? rows[0].json_file : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
recent(options: { limit?: number; tags?: string[]; since?: string; until?: string } = {}): IndexSearchHit[] {
|
|
401
|
+
const limit = Math.min(50, Math.max(1, options.limit || 20));
|
|
402
|
+
const tags = (options.tags || []).map((tag) => tag.trim()).filter(Boolean);
|
|
403
|
+
return this.filterRows({ tags, since: options.since, until: options.until }, limit).map((row) => toHit(row, 0));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
tags(options: { includeSingletons?: boolean; limit?: number } = {}): { tag: string; count: number }[] {
|
|
407
|
+
const counts = new Map<string, number>();
|
|
408
|
+
const rows = this.db.prepare('SELECT tags FROM notes').all() as { tags: string }[];
|
|
409
|
+
for (const row of rows) {
|
|
410
|
+
for (const tag of parseTags(row.tags)) {
|
|
411
|
+
counts.set(tag, (counts.get(tag) || 0) + 1);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const min = options.includeSingletons ? 1 : 2;
|
|
415
|
+
const limit = Math.min(500, Math.max(1, options.limit || 200));
|
|
416
|
+
return [...counts.entries()]
|
|
417
|
+
.filter(([, count]) => count >= min)
|
|
418
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
419
|
+
.slice(0, limit)
|
|
420
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
list(options: IndexListOptions = {}): IndexSummary[] {
|
|
424
|
+
const where: string[] = [];
|
|
425
|
+
const params: Array<string | number> = [];
|
|
426
|
+
if (options.unreadable) {
|
|
427
|
+
where.push("audio_error IS NOT NULL AND audio_error != ''");
|
|
428
|
+
}
|
|
429
|
+
if (options.starred) {
|
|
430
|
+
where.push('starred = 1');
|
|
431
|
+
}
|
|
432
|
+
if (options.year) {
|
|
433
|
+
where.push("(folder IN ('holding', 'unfiled') OR year = ?)");
|
|
434
|
+
params.push(options.year);
|
|
435
|
+
}
|
|
436
|
+
if (options.month && options.year) {
|
|
437
|
+
where.push("(folder IN ('holding', 'unfiled') OR month = ?)");
|
|
438
|
+
params.push(options.month);
|
|
439
|
+
}
|
|
440
|
+
if (!options.all && !options.year && !options.month && !options.unreadable && !options.starred) {
|
|
441
|
+
const newest = this.db
|
|
442
|
+
.prepare("SELECT MAX(year) AS year FROM notes WHERE folder = 'journal' AND year != ''")
|
|
443
|
+
.get() as { year: string | null };
|
|
444
|
+
if (newest?.year) {
|
|
445
|
+
where.push("(folder IN ('holding', 'unfiled') OR year = ?)");
|
|
446
|
+
params.push(newest.year);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const sql = `SELECT json_file, basename, day, year, month, folder, tags, preview, has_cleaned, audio_error, starred
|
|
450
|
+
FROM notes ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
451
|
+
ORDER BY day DESC, basename DESC`;
|
|
452
|
+
const rows = this.db.prepare(sql).all(...params) as Array<
|
|
453
|
+
Omit<NoteRow, 'mtime_ms' | 'text_hash' | 'body' | 'raw'>
|
|
454
|
+
>;
|
|
455
|
+
return rows.map(toSummary);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
search(options: {
|
|
459
|
+
query?: string;
|
|
460
|
+
tags?: string[];
|
|
461
|
+
since?: string;
|
|
462
|
+
until?: string;
|
|
463
|
+
year?: string;
|
|
464
|
+
month?: string;
|
|
465
|
+
mode?: SearchMode;
|
|
466
|
+
sort?: SearchSort;
|
|
467
|
+
limit?: number;
|
|
468
|
+
unreadable?: boolean;
|
|
469
|
+
starred?: boolean;
|
|
470
|
+
queryEmbedding?: number[] | null;
|
|
471
|
+
synonyms?: string[];
|
|
472
|
+
}): IndexSearchHit[] {
|
|
473
|
+
const limit = Math.min(50, Math.max(1, options.limit || 20));
|
|
474
|
+
const tags = (options.tags || []).map((tag) => tag.trim()).filter(Boolean);
|
|
475
|
+
const query = String(options.query || '').trim();
|
|
476
|
+
const range = yearMonthRange(options.year, options.month);
|
|
477
|
+
const since = options.since || range.since;
|
|
478
|
+
const until = options.until || range.until;
|
|
479
|
+
const filenameQuery = isFilenameQuery(query);
|
|
480
|
+
const mode = filenameQuery ? 'lex' : options.mode || 'lex';
|
|
481
|
+
const filters = {
|
|
482
|
+
tags,
|
|
483
|
+
since,
|
|
484
|
+
until,
|
|
485
|
+
unreadable: options.unreadable,
|
|
486
|
+
starred: options.starred,
|
|
487
|
+
};
|
|
488
|
+
if (!query && !tags.length && !options.unreadable && !options.starred && !since && !until) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let hits: IndexSearchHit[];
|
|
493
|
+
if (!query) {
|
|
494
|
+
hits = this.filterRows(filters, limit).map((row) => toHit(row, 0));
|
|
495
|
+
} else {
|
|
496
|
+
const lexHits = this.searchLex(query, { ...filters, limit: 80, synonyms: options.synonyms });
|
|
497
|
+
if (mode === 'lex' || !options.queryEmbedding || !this.hasVec()) {
|
|
498
|
+
hits = lexHits.slice(0, limit);
|
|
499
|
+
} else {
|
|
500
|
+
const semHits = this.searchSemantic(options.queryEmbedding, { ...filters, limit: 80 });
|
|
501
|
+
hits = mode === 'semantic' ? semHits.slice(0, limit) : rrfMerge(lexHits, semHits, limit);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return sortHits(hits, options.sort || (query ? 'relevance' : 'recent'));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private searchLex(
|
|
508
|
+
query: string,
|
|
509
|
+
options: {
|
|
510
|
+
tags: string[];
|
|
511
|
+
since?: string;
|
|
512
|
+
until?: string;
|
|
513
|
+
unreadable?: boolean;
|
|
514
|
+
starred?: boolean;
|
|
515
|
+
limit: number;
|
|
516
|
+
synonyms?: string[];
|
|
517
|
+
},
|
|
518
|
+
): IndexSearchHit[] {
|
|
519
|
+
const match = buildFtsQuery(query, options.synonyms);
|
|
520
|
+
const { extra, params } = this.filterSql(options);
|
|
521
|
+
if (match) {
|
|
522
|
+
try {
|
|
523
|
+
const rows = this.db
|
|
524
|
+
.prepare(
|
|
525
|
+
`SELECT n.json_file, n.basename, n.day, n.tags, n.preview, n.has_cleaned, n.audio_error, n.starred,
|
|
526
|
+
n.body, snippet(notes_fts, 2, '', '', '…', 24) AS snippet,
|
|
527
|
+
bm25(notes_fts) AS rank
|
|
528
|
+
FROM notes_fts
|
|
529
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
530
|
+
WHERE notes_fts MATCH ? ${extra}
|
|
531
|
+
ORDER BY rank
|
|
532
|
+
LIMIT ?`,
|
|
533
|
+
)
|
|
534
|
+
.all(match, ...params, options.limit) as Array<
|
|
535
|
+
Omit<NoteRow, 'mtime_ms' | 'text_hash' | 'raw' | 'year' | 'month' | 'folder'> & {
|
|
536
|
+
rank: number;
|
|
537
|
+
snippet?: string;
|
|
538
|
+
}
|
|
539
|
+
>;
|
|
540
|
+
return rows.map((row) =>
|
|
541
|
+
toHit(row, Number(row.rank) || 0, {
|
|
542
|
+
snippet: String(row.snippet || '').trim() || undefined,
|
|
543
|
+
cue: cueIndexForQuery(row.body, query),
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
} catch {
|
|
547
|
+
// hyphenated filenames and other FTS syntax — fall through to LIKE
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return this.searchBasenameLike(query, { ...options, extra, params });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private searchBasenameLike(
|
|
554
|
+
query: string,
|
|
555
|
+
options: { extra: string; params: Array<string | number>; limit: number },
|
|
556
|
+
): IndexSearchHit[] {
|
|
557
|
+
const needle = String(query || '')
|
|
558
|
+
.replace(/^filename:\s*/i, '')
|
|
559
|
+
.trim()
|
|
560
|
+
.replace(/[!%_]/g, (char) => `!${char}`);
|
|
561
|
+
if (needle.replace(/!/g, '').length < 2) return [];
|
|
562
|
+
const rows = this.db
|
|
563
|
+
.prepare(
|
|
564
|
+
`SELECT n.json_file, n.basename, n.day, n.tags, n.preview, n.has_cleaned, n.audio_error, n.starred
|
|
565
|
+
FROM notes n
|
|
566
|
+
WHERE lower(n.basename) LIKE ? ESCAPE '!' ${options.extra}
|
|
567
|
+
ORDER BY n.day DESC, n.basename DESC
|
|
568
|
+
LIMIT ?`,
|
|
569
|
+
)
|
|
570
|
+
.all(`%${needle.toLowerCase()}%`, ...options.params, options.limit) as Array<
|
|
571
|
+
Omit<NoteRow, 'mtime_ms' | 'text_hash' | 'body' | 'raw' | 'year' | 'month' | 'folder'>
|
|
572
|
+
>;
|
|
573
|
+
return rows.map((row) => toHit(row, 0));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private searchSemantic(
|
|
577
|
+
embedding: number[],
|
|
578
|
+
options: { tags: string[]; since?: string; until?: string; unreadable?: boolean; starred?: boolean; limit: number },
|
|
579
|
+
): IndexSearchHit[] {
|
|
580
|
+
const { extra, params } = this.filterSql(options);
|
|
581
|
+
const rows = this.db
|
|
582
|
+
.prepare(
|
|
583
|
+
`SELECT n.json_file, n.basename, n.day, n.tags, n.preview, n.has_cleaned, n.audio_error, n.starred,
|
|
584
|
+
v.distance AS rank
|
|
585
|
+
FROM notes_vec v
|
|
586
|
+
JOIN notes n ON n.rowid = v.rowid
|
|
587
|
+
WHERE v.embedding MATCH ? AND k = ? ${extra}
|
|
588
|
+
ORDER BY v.distance`,
|
|
589
|
+
)
|
|
590
|
+
.all(JSON.stringify(embedding), options.limit, ...params) as Array<
|
|
591
|
+
Omit<NoteRow, 'mtime_ms' | 'text_hash' | 'body' | 'raw' | 'year' | 'month' | 'folder'> & { rank: number }
|
|
592
|
+
>;
|
|
593
|
+
return rows.map((row) => toHit(row, Number(row.rank) || 0));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private filterRows(
|
|
597
|
+
options: { tags: string[]; since?: string; until?: string; unreadable?: boolean; starred?: boolean },
|
|
598
|
+
limit: number,
|
|
599
|
+
): NoteRow[] {
|
|
600
|
+
const { extra, params } = this.filterSql(options);
|
|
601
|
+
return this.db
|
|
602
|
+
.prepare(`SELECT * FROM notes n WHERE 1=1 ${extra} ORDER BY day DESC, basename DESC LIMIT ?`)
|
|
603
|
+
.all(...params, limit) as NoteRow[];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private filterSql(options: {
|
|
607
|
+
tags: string[];
|
|
608
|
+
since?: string;
|
|
609
|
+
until?: string;
|
|
610
|
+
unreadable?: boolean;
|
|
611
|
+
starred?: boolean;
|
|
612
|
+
}): {
|
|
613
|
+
extra: string;
|
|
614
|
+
params: Array<string | number>;
|
|
615
|
+
} {
|
|
616
|
+
const extra: string[] = [];
|
|
617
|
+
const params: Array<string | number> = [];
|
|
618
|
+
if (options.since) {
|
|
619
|
+
extra.push('AND n.day >= ?');
|
|
620
|
+
params.push(options.since);
|
|
621
|
+
}
|
|
622
|
+
if (options.until) {
|
|
623
|
+
extra.push('AND n.day <= ?');
|
|
624
|
+
params.push(options.until);
|
|
625
|
+
}
|
|
626
|
+
if (options.unreadable) {
|
|
627
|
+
extra.push("AND n.audio_error IS NOT NULL AND n.audio_error != ''");
|
|
628
|
+
}
|
|
629
|
+
if (options.starred) {
|
|
630
|
+
extra.push('AND n.starred = 1');
|
|
631
|
+
}
|
|
632
|
+
for (const tag of options.tags) {
|
|
633
|
+
extra.push('AND lower(n.tags) LIKE ?');
|
|
634
|
+
params.push(`%${JSON.stringify(tag.toLowerCase()).slice(1, -1)}%`);
|
|
635
|
+
}
|
|
636
|
+
return { extra: extra.join(' '), params };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function toSummary(row: {
|
|
641
|
+
json_file: string;
|
|
642
|
+
basename: string;
|
|
643
|
+
day: string;
|
|
644
|
+
year: string;
|
|
645
|
+
month: string;
|
|
646
|
+
folder: string;
|
|
647
|
+
tags: string;
|
|
648
|
+
preview: string;
|
|
649
|
+
has_cleaned: number;
|
|
650
|
+
audio_error: string | null;
|
|
651
|
+
starred?: number;
|
|
652
|
+
}): IndexSummary {
|
|
653
|
+
return {
|
|
654
|
+
jsonFile: row.json_file,
|
|
655
|
+
basename: row.basename,
|
|
656
|
+
day: row.day,
|
|
657
|
+
year: row.year,
|
|
658
|
+
month: row.month,
|
|
659
|
+
folder: row.folder,
|
|
660
|
+
tags: parseTags(row.tags),
|
|
661
|
+
preview: row.preview,
|
|
662
|
+
hasCleaned: Boolean(row.has_cleaned),
|
|
663
|
+
audioError: row.audio_error,
|
|
664
|
+
starred: Boolean(row.starred),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function toHit(
|
|
669
|
+
row: {
|
|
670
|
+
json_file: string;
|
|
671
|
+
basename: string;
|
|
672
|
+
day: string;
|
|
673
|
+
tags: string;
|
|
674
|
+
preview: string;
|
|
675
|
+
has_cleaned: number;
|
|
676
|
+
audio_error: string | null;
|
|
677
|
+
starred?: number;
|
|
678
|
+
},
|
|
679
|
+
score: number,
|
|
680
|
+
extra: { snippet?: string; cue?: number | null } = {},
|
|
681
|
+
): IndexSearchHit {
|
|
682
|
+
return {
|
|
683
|
+
jsonFile: row.json_file,
|
|
684
|
+
basename: row.basename,
|
|
685
|
+
day: row.day,
|
|
686
|
+
tags: parseTags(row.tags),
|
|
687
|
+
preview: row.preview,
|
|
688
|
+
snippet: extra.snippet,
|
|
689
|
+
cue: extra.cue ?? null,
|
|
690
|
+
score,
|
|
691
|
+
hasCleaned: Boolean(row.has_cleaned),
|
|
692
|
+
audioError: row.audio_error,
|
|
693
|
+
starred: Boolean(row.starred),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function parseTags(raw: string): string[] {
|
|
698
|
+
try {
|
|
699
|
+
const parsed = JSON.parse(raw);
|
|
700
|
+
return Array.isArray(parsed) ? parsed.map((tag) => String(tag).trim()).filter(Boolean) : [];
|
|
701
|
+
} catch {
|
|
702
|
+
return [];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function folderOf(jsonFile: string): string {
|
|
707
|
+
const norm = jsonFile.replace(/\\/g, '/');
|
|
708
|
+
if (/\/_holding(?:\/|$)/i.test(norm)) return 'holding';
|
|
709
|
+
if (/\/_unfiled(?:\/|$)/i.test(norm)) return 'unfiled';
|
|
710
|
+
return 'journal';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function previewOf(text: string): string {
|
|
714
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
715
|
+
if (oneLine.length <= PREVIEW_LIMIT) return oneLine;
|
|
716
|
+
return `${oneLine.slice(0, PREVIEW_LIMIT)}…`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function readSidecarRow(jsonFile: string): NoteRow | null {
|
|
720
|
+
const resolved = path.resolve(jsonFile);
|
|
721
|
+
if (!fs.existsSync(resolved)) return null;
|
|
722
|
+
let json: Record<string, unknown>;
|
|
723
|
+
try {
|
|
724
|
+
json = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
725
|
+
} catch {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const cleaned = String(json.cleanedTranscription || '').trim();
|
|
729
|
+
const raw = String(json.text || '').trim();
|
|
730
|
+
const source = cleaned || raw;
|
|
731
|
+
const audioError = json.audioError ? String(json.audioError) : null;
|
|
732
|
+
if (!source && !audioError && !Array.isArray(json.segments)) return null;
|
|
733
|
+
const tags = Array.isArray(json.tags)
|
|
734
|
+
? json.tags.map((tag) => String(tag).trim()).filter(Boolean)
|
|
735
|
+
: [];
|
|
736
|
+
let mtimeMs = 0;
|
|
737
|
+
try {
|
|
738
|
+
mtimeMs = fs.statSync(resolved).mtimeMs;
|
|
739
|
+
} catch {
|
|
740
|
+
mtimeMs = 0;
|
|
741
|
+
}
|
|
742
|
+
const basename = path.basename(resolved);
|
|
743
|
+
const day = dayOf(resolved, basename.replace(/\.json$/i, ''), mtimeMs);
|
|
744
|
+
const starred = json.starred === true || json.starred === 1 ? 1 : 0;
|
|
745
|
+
const textHash = createHash('sha1').update(`${cleaned}\0${raw}\0${tags.join(',')}\0${starred}`).digest('hex');
|
|
746
|
+
return {
|
|
747
|
+
json_file: resolved,
|
|
748
|
+
basename,
|
|
749
|
+
day,
|
|
750
|
+
year: day.slice(0, 4),
|
|
751
|
+
month: day.slice(5, 7),
|
|
752
|
+
folder: folderOf(resolved),
|
|
753
|
+
tags: JSON.stringify(tags),
|
|
754
|
+
preview: audioError && !source ? '[unreadable audio]' : previewOf(source),
|
|
755
|
+
has_cleaned: cleaned ? 1 : 0,
|
|
756
|
+
audio_error: audioError,
|
|
757
|
+
starred,
|
|
758
|
+
mtime_ms: mtimeMs,
|
|
759
|
+
text_hash: textHash,
|
|
760
|
+
body: cleaned || raw,
|
|
761
|
+
raw,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const FTS_WORDS = new Set(['and', 'or', 'not', 'near']);
|
|
766
|
+
|
|
767
|
+
/** House names and Whisper typos. Prompt terms are passed in at query time. */
|
|
768
|
+
export const HOUSE_SEARCH_NAMES = ['Kristen', 'Kisten', 'Kristin', 'Mindcorp'];
|
|
769
|
+
|
|
770
|
+
const HOUSE_GROUPS = [
|
|
771
|
+
['kristen', 'kisten', 'kristin'],
|
|
772
|
+
['mindcorp', 'mind-corp'],
|
|
773
|
+
];
|
|
774
|
+
|
|
775
|
+
export function editDistance(a: string, b: string): number {
|
|
776
|
+
const left = String(a || '');
|
|
777
|
+
const right = String(b || '');
|
|
778
|
+
const rows = left.length + 1;
|
|
779
|
+
const cols = right.length + 1;
|
|
780
|
+
const grid = Array.from({ length: rows }, (_, i) => {
|
|
781
|
+
const row = new Array<number>(cols);
|
|
782
|
+
row[0] = i;
|
|
783
|
+
return row;
|
|
784
|
+
});
|
|
785
|
+
for (let j = 0; j < cols; j += 1) grid[0][j] = j;
|
|
786
|
+
for (let i = 1; i < rows; i += 1) {
|
|
787
|
+
for (let j = 1; j < cols; j += 1) {
|
|
788
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
789
|
+
grid[i][j] = Math.min(grid[i - 1][j] + 1, grid[i][j - 1] + 1, grid[i - 1][j - 1] + cost);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return grid[left.length][right.length];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function synonymGroupFor(token: string, extraTerms: string[] = []): string[] {
|
|
796
|
+
const raw = String(token || '').trim();
|
|
797
|
+
if (!raw) return [];
|
|
798
|
+
const lower = raw.toLowerCase();
|
|
799
|
+
const hits = new Set<string>([raw]);
|
|
800
|
+
const pool = [...HOUSE_SEARCH_NAMES, ...extraTerms].map((term) => String(term || '').trim()).filter(Boolean);
|
|
801
|
+
for (const term of pool) {
|
|
802
|
+
const t = term.toLowerCase();
|
|
803
|
+
if (t === lower || (lower.length >= 4 && t.length >= 4 && editDistance(lower, t) <= 1)) {
|
|
804
|
+
hits.add(term);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const group of HOUSE_GROUPS) {
|
|
808
|
+
if (group.includes(lower) || [...hits].some((hit) => group.includes(hit.toLowerCase()))) {
|
|
809
|
+
for (const alias of group) hits.add(alias);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return [...hits];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function queryTokens(query: string): string[] {
|
|
816
|
+
return String(query || '')
|
|
817
|
+
.replace(/^filename:\s*/i, '')
|
|
818
|
+
.split(/[^\p{L}\p{N}*]+/u)
|
|
819
|
+
.map((token) => token.replace(/\*+$/g, ''))
|
|
820
|
+
.filter((token) => token.length > 1 || /\p{L}/u.test(token));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function noteSections(text: string): string[] {
|
|
824
|
+
const trimmed = String(text || '').trim();
|
|
825
|
+
if (!trimmed) return [];
|
|
826
|
+
const paras = trimmed.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
|
|
827
|
+
if (paras.length >= 2) return paras;
|
|
828
|
+
const sentences = trimmed.split(/(?<=[.!?])\s+/).map((part) => part.trim()).filter(Boolean);
|
|
829
|
+
return sentences.length >= 2 ? sentences : [trimmed];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function cueIndexForQuery(body: string, query: string): number | null {
|
|
833
|
+
if (isFilenameQuery(query)) return null;
|
|
834
|
+
const tokens = queryTokens(query).map((token) => token.toLowerCase());
|
|
835
|
+
if (!tokens.length) return null;
|
|
836
|
+
const sections = noteSections(body);
|
|
837
|
+
const index = sections.findIndex((section) => {
|
|
838
|
+
const lower = section.toLowerCase();
|
|
839
|
+
return tokens.some((token) => lower.includes(token));
|
|
840
|
+
});
|
|
841
|
+
return index >= 0 ? index : null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function isFilenameQuery(query: string): boolean {
|
|
845
|
+
const raw = String(query || '').replace(/^filename:\s*/i, '').trim();
|
|
846
|
+
if (!raw) return false;
|
|
847
|
+
if (/^filename:/i.test(query)) return true;
|
|
848
|
+
return /\d{4}[-_.]\d{2}[-_.]\d{2}/.test(raw) || /\.(mp3|m4a|wav|webm|ogg|json)$/i.test(raw);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function ftsToken(token: string): string {
|
|
852
|
+
return FTS_WORDS.has(token.toLowerCase()) ? `"${token}"` : `${token}*`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function buildFtsQuery(query: string, extraTerms: string[] = []): string {
|
|
856
|
+
const filename = query.match(/^filename:\s*(.+)$/i);
|
|
857
|
+
const raw = filename ? filename[1] : query;
|
|
858
|
+
const looksLikeName = isFilenameQuery(query);
|
|
859
|
+
const tokens = queryTokens(raw);
|
|
860
|
+
if (!tokens.length) return '';
|
|
861
|
+
const parts = tokens.map((token) => {
|
|
862
|
+
if (looksLikeName) return ftsToken(token);
|
|
863
|
+
const group = synonymGroupFor(token, extraTerms);
|
|
864
|
+
if (group.length <= 1) return ftsToken(token);
|
|
865
|
+
return `(${group.map((alias) => ftsToken(alias)).join(' OR ')})`;
|
|
866
|
+
});
|
|
867
|
+
const joined = parts.join(' AND ');
|
|
868
|
+
return looksLikeName ? `{basename} : ${joined}` : joined;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function yearMonthRange(year?: string, month?: string): { since?: string; until?: string } {
|
|
872
|
+
const y = String(year || '').trim();
|
|
873
|
+
if (!/^\d{4}$/.test(y)) return {};
|
|
874
|
+
const m = String(month || '').trim().padStart(2, '0');
|
|
875
|
+
if (/^\d{2}$/.test(m) && Number(m) >= 1 && Number(m) <= 12) {
|
|
876
|
+
const last = new Date(Number(y), Number(m), 0).getDate();
|
|
877
|
+
return { since: `${y}-${m}-01`, until: `${y}-${m}-${String(last).padStart(2, '0')}` };
|
|
878
|
+
}
|
|
879
|
+
return { since: `${y}-01-01`, until: `${y}-12-31` };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function sortHits(hits: IndexSearchHit[], sort: SearchSort): IndexSearchHit[] {
|
|
883
|
+
if (sort === 'relevance') return hits;
|
|
884
|
+
const copy = [...hits];
|
|
885
|
+
copy.sort((a, b) => {
|
|
886
|
+
const day = String(a.day).localeCompare(String(b.day));
|
|
887
|
+
const name = String(a.basename).localeCompare(String(b.basename));
|
|
888
|
+
return sort === 'oldest' ? day || name : -(day || name);
|
|
889
|
+
});
|
|
890
|
+
return copy;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function rrfMerge(lex: IndexSearchHit[], sem: IndexSearchHit[], limit: number): IndexSearchHit[] {
|
|
894
|
+
const k = 60;
|
|
895
|
+
const scores = new Map<string, { hit: IndexSearchHit; score: number }>();
|
|
896
|
+
lex.forEach((hit, i) => {
|
|
897
|
+
scores.set(hit.jsonFile, { hit, score: 1 / (k + i + 1) });
|
|
898
|
+
});
|
|
899
|
+
sem.forEach((hit, i) => {
|
|
900
|
+
const prev = scores.get(hit.jsonFile);
|
|
901
|
+
const add = 1 / (k + i + 1);
|
|
902
|
+
if (prev) prev.score += add;
|
|
903
|
+
else scores.set(hit.jsonFile, { hit, score: add });
|
|
904
|
+
});
|
|
905
|
+
return [...scores.values()]
|
|
906
|
+
.sort((a, b) => b.score - a.score)
|
|
907
|
+
.slice(0, limit)
|
|
908
|
+
.map((row) => ({ ...row.hit, score: row.score }));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let singleton: JournalIndex | null = null;
|
|
912
|
+
|
|
913
|
+
export function openJournalIndex(dbPath: string): JournalIndex {
|
|
914
|
+
return new JournalIndex(dbPath);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function getJournalIndex(): JournalIndex | null {
|
|
918
|
+
return singleton;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export function setJournalIndex(index: JournalIndex | null) {
|
|
922
|
+
singleton = index;
|
|
923
|
+
}
|