pixmap-engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +15 -0
- package/README.md +103 -0
- package/dist/embedder.js +75 -0
- package/dist/engine.js +66 -0
- package/dist/index.js +259 -0
- package/dist/indexer.js +10 -0
- package/dist/metadataDb.js +69 -0
- package/dist/preview.js +216 -0
- package/dist/searcher.js +17 -0
- package/dist/types.js +3 -0
- package/dist/vectorStore.js +47 -0
- package/docs/HOW.md +177 -0
- package/package.json +33 -0
- package/src/embedder.ts +100 -0
- package/src/engine.ts +106 -0
- package/src/index.ts +310 -0
- package/src/indexer.ts +21 -0
- package/src/metadataDb.ts +87 -0
- package/src/preview.ts +292 -0
- package/src/searcher.ts +30 -0
- package/src/types.ts +19 -0
- package/src/vectorStore.ts +59 -0
- package/tsconfig.json +16 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_TOP_K, ImageRecord, SearchResult } from './types.js';
|
|
4
|
+
|
|
5
|
+
type EmbedderInstance = {
|
|
6
|
+
init: () => Promise<void>;
|
|
7
|
+
embed: (imagePath: string) => Promise<Float32Array>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type StoreInstance = {
|
|
11
|
+
initOrLoad: (indexPath: string) => void;
|
|
12
|
+
save: (indexPath: string) => void;
|
|
13
|
+
getCount: () => number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type DbInstance = {
|
|
17
|
+
get: (id: number) => ImageRecord | undefined;
|
|
18
|
+
list: (limit?: number) => ImageRecord[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PixmapEngineOptions = {
|
|
22
|
+
dataDir: string;
|
|
23
|
+
indexFileName?: string;
|
|
24
|
+
dbFileName?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class PixmapEngine {
|
|
28
|
+
private readonly dataDir: string;
|
|
29
|
+
private readonly indexPath: string;
|
|
30
|
+
private readonly dbPath: string;
|
|
31
|
+
|
|
32
|
+
private embedder: EmbedderInstance | null = null;
|
|
33
|
+
private store: StoreInstance | null = null;
|
|
34
|
+
private db: DbInstance | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(options: PixmapEngineOptions) {
|
|
37
|
+
this.dataDir = path.resolve(options.dataDir);
|
|
38
|
+
this.indexPath = path.join(this.dataDir, options.indexFileName ?? 'index.hnsw');
|
|
39
|
+
this.dbPath = path.join(this.dataDir, options.dbFileName ?? 'metadata.db');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async init(): Promise<void> {
|
|
43
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const [{ ImageEmbedder }, { VectorStore }, { MetadataDb }] = await Promise.all([
|
|
46
|
+
import('./embedder.js'),
|
|
47
|
+
import('./vectorStore.js'),
|
|
48
|
+
import('./metadataDb.js'),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
this.embedder = new ImageEmbedder();
|
|
52
|
+
await this.embedder.init();
|
|
53
|
+
|
|
54
|
+
this.store = new VectorStore();
|
|
55
|
+
this.store.initOrLoad(this.indexPath);
|
|
56
|
+
|
|
57
|
+
this.db = new MetadataDb(this.dbPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async add(imagePath: string): Promise<{ id: number; skipped: boolean; record?: ImageRecord }> {
|
|
61
|
+
this.assertInitialized();
|
|
62
|
+
|
|
63
|
+
const absolute = path.resolve(imagePath);
|
|
64
|
+
const { addImage } = await import('./indexer.js');
|
|
65
|
+
const result = await (addImage as any)(absolute, this.embedder!, this.store!, this.db!);
|
|
66
|
+
this.store!.save(this.indexPath);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...result,
|
|
70
|
+
record: this.db!.get(result.id),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async search(queryImagePath: string, topK = DEFAULT_TOP_K): Promise<SearchResult[]> {
|
|
75
|
+
this.assertInitialized();
|
|
76
|
+
const absolute = path.resolve(queryImagePath);
|
|
77
|
+
|
|
78
|
+
if (this.store!.getCount() === 0) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { findSimilar } = await import('./searcher.js');
|
|
83
|
+
return (findSimilar as any)(absolute, this.embedder!, this.store!, this.db!, topK);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
listImages(limit = 200): ImageRecord[] {
|
|
87
|
+
this.assertInitialized();
|
|
88
|
+
return this.db!.list(limit);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getImage(id: number): ImageRecord | undefined {
|
|
92
|
+
this.assertInitialized();
|
|
93
|
+
return this.db!.get(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getIndexedCount(): number {
|
|
97
|
+
this.assertInitialized();
|
|
98
|
+
return this.store!.getCount();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private assertInitialized(): void {
|
|
102
|
+
if (!this.embedder || !this.store || !this.db) {
|
|
103
|
+
throw new Error('PixmapEngine not initialized. Call init() first.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ImageEmbedder } from './embedder.js';
|
|
6
|
+
import { addImage } from './indexer.js';
|
|
7
|
+
import { MetadataDb } from './metadataDb.js';
|
|
8
|
+
import { renderImage, renderImageRow } from './preview.js';
|
|
9
|
+
import { findSimilar } from './searcher.js';
|
|
10
|
+
import { DEFAULT_TOP_K } from './types.js';
|
|
11
|
+
import { VectorStore } from './vectorStore.js';
|
|
12
|
+
|
|
13
|
+
type CliOptions = {
|
|
14
|
+
dataDir: string;
|
|
15
|
+
topK: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.avif', '.tiff']);
|
|
19
|
+
|
|
20
|
+
async function main(): Promise<void> {
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
|
|
23
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
24
|
+
printUsage();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const command = args[0];
|
|
29
|
+
const rest = args.slice(1);
|
|
30
|
+
const options = parseOptions(rest);
|
|
31
|
+
|
|
32
|
+
const dataDir = path.resolve(options.dataDir);
|
|
33
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const dbPath = path.join(dataDir, 'metadata.db');
|
|
36
|
+
const indexPath = path.join(dataDir, 'index.hnsw');
|
|
37
|
+
|
|
38
|
+
if (command === 'list') {
|
|
39
|
+
const db = new MetadataDb(dbPath);
|
|
40
|
+
const images = db.list(200);
|
|
41
|
+
|
|
42
|
+
if (images.length === 0) {
|
|
43
|
+
console.log('No indexed images.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`\n Indexed images (${images.length}):\n`);
|
|
48
|
+
for (const img of images) {
|
|
49
|
+
const date = new Date(img.created * 1000).toISOString().slice(0, 19);
|
|
50
|
+
const name = path.basename(img.path);
|
|
51
|
+
console.log(` [${img.id}] ${date} ${name}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (command === 'show') {
|
|
58
|
+
const target = rest.find((x) => !x.startsWith('--'));
|
|
59
|
+
if (!target) {
|
|
60
|
+
console.error('Error: Provide an image path or id.\n');
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let imagePath: string;
|
|
66
|
+
const asId = Number(target);
|
|
67
|
+
if (Number.isFinite(asId) && asId > 0) {
|
|
68
|
+
const db = new MetadataDb(dbPath);
|
|
69
|
+
const record = db.get(asId);
|
|
70
|
+
if (!record) {
|
|
71
|
+
console.error(`Error: No image with id ${asId}`);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
imagePath = record.path;
|
|
76
|
+
} else {
|
|
77
|
+
imagePath = path.resolve(target);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
assertImageExists(imagePath);
|
|
81
|
+
console.log();
|
|
82
|
+
const preview = await renderImage(imagePath);
|
|
83
|
+
console.log(preview);
|
|
84
|
+
console.log(` ${path.basename(imagePath)}\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (command === 'status') {
|
|
89
|
+
const db = new MetadataDb(dbPath);
|
|
90
|
+
const store = new VectorStore();
|
|
91
|
+
store.initOrLoad(indexPath);
|
|
92
|
+
const images = db.list(999999);
|
|
93
|
+
|
|
94
|
+
console.log(`\n pixmap status`);
|
|
95
|
+
console.log(` data dir: ${dataDir}`);
|
|
96
|
+
console.log(` vectors: ${store.getCount()}`);
|
|
97
|
+
console.log(` images: ${images.length}`);
|
|
98
|
+
console.log();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Commands below require the embedder
|
|
103
|
+
console.log('Loading CLIP model...');
|
|
104
|
+
const embedder = new ImageEmbedder();
|
|
105
|
+
await embedder.init();
|
|
106
|
+
|
|
107
|
+
const store = new VectorStore();
|
|
108
|
+
store.initOrLoad(indexPath);
|
|
109
|
+
|
|
110
|
+
const db = new MetadataDb(dbPath);
|
|
111
|
+
|
|
112
|
+
if (command === 'add') {
|
|
113
|
+
const targets = rest.filter((x) => !x.startsWith('--'));
|
|
114
|
+
if (targets.length === 0) {
|
|
115
|
+
console.error('Error: No image path provided.\n');
|
|
116
|
+
printUsage();
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let added = 0;
|
|
122
|
+
let skipped = 0;
|
|
123
|
+
|
|
124
|
+
for (const target of targets) {
|
|
125
|
+
const absolutePath = path.resolve(target);
|
|
126
|
+
const stat = safeStat(absolutePath);
|
|
127
|
+
|
|
128
|
+
if (!stat) {
|
|
129
|
+
console.error(` skip: not found — ${absolutePath}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const files = stat.isDirectory() ? walkImages(absolutePath) : [absolutePath];
|
|
134
|
+
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
const result = await addImage(file, embedder, store, db);
|
|
137
|
+
if (result.skipped) {
|
|
138
|
+
skipped += 1;
|
|
139
|
+
console.log(` skip: already indexed — ${path.basename(file)}`);
|
|
140
|
+
} else {
|
|
141
|
+
added += 1;
|
|
142
|
+
console.log(` added: id=${result.id} — ${path.basename(file)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
store.save(indexPath);
|
|
148
|
+
console.log(`\nDone. added=${added} skipped=${skipped}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (command === 'search' || command === 'similar') {
|
|
153
|
+
const imagePath = rest.find((x) => !x.startsWith('--'));
|
|
154
|
+
if (!imagePath) {
|
|
155
|
+
console.error('Error: No query image provided.\n');
|
|
156
|
+
printUsage();
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const absolutePath = path.resolve(imagePath);
|
|
162
|
+
assertImageExists(absolutePath);
|
|
163
|
+
|
|
164
|
+
if (store.getCount() === 0) {
|
|
165
|
+
console.log('Index is empty. Add images first with: pixmap add <path>');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Show query image preview
|
|
170
|
+
console.log(`\n Query:\n`);
|
|
171
|
+
const queryPreview = await renderImage(absolutePath, undefined, 20);
|
|
172
|
+
console.log(queryPreview);
|
|
173
|
+
console.log(` ${path.basename(absolutePath)}\n`);
|
|
174
|
+
|
|
175
|
+
const results = await findSimilar(absolutePath, embedder, store, db, options.topK);
|
|
176
|
+
|
|
177
|
+
if (results.length === 0) {
|
|
178
|
+
console.log(' No similar images found.\n');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(` ── Results (${results.length}) ──\n`);
|
|
183
|
+
|
|
184
|
+
const PER_ROW = 3;
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < results.length; i += PER_ROW) {
|
|
187
|
+
const batch = results.slice(i, i + PER_ROW);
|
|
188
|
+
const panels = batch
|
|
189
|
+
.map((r, j) => {
|
|
190
|
+
const pct = (r.score * 100).toFixed(1);
|
|
191
|
+
const name = path.basename(r.path);
|
|
192
|
+
return fs.existsSync(r.path)
|
|
193
|
+
? { label: `#${i + j + 1} ${pct}% ${name}`, imagePath: r.path }
|
|
194
|
+
: null;
|
|
195
|
+
})
|
|
196
|
+
.filter((p): p is NonNullable<typeof p> => p !== null);
|
|
197
|
+
|
|
198
|
+
if (panels.length > 0) {
|
|
199
|
+
const row = await renderImageRow(panels);
|
|
200
|
+
console.log(row);
|
|
201
|
+
console.log();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.error(`Unknown command: ${command}\n`);
|
|
208
|
+
printUsage();
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseOptions(args: string[]): CliOptions {
|
|
213
|
+
let dataDir = 'data';
|
|
214
|
+
let topK = DEFAULT_TOP_K;
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
217
|
+
const arg = args[i];
|
|
218
|
+
|
|
219
|
+
if (arg === '--data-dir' || arg === '-d') {
|
|
220
|
+
dataDir = args[i + 1] ?? dataDir;
|
|
221
|
+
i += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (arg === '--top-k' || arg === '-k') {
|
|
226
|
+
const parsed = Number(args[i + 1]);
|
|
227
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
228
|
+
topK = Math.floor(parsed);
|
|
229
|
+
}
|
|
230
|
+
i += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { dataDir, topK };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function walkImages(rootDir: string): string[] {
|
|
239
|
+
const result: string[] = [];
|
|
240
|
+
const stack = [rootDir];
|
|
241
|
+
|
|
242
|
+
while (stack.length > 0) {
|
|
243
|
+
const current = stack.pop();
|
|
244
|
+
if (!current) continue;
|
|
245
|
+
|
|
246
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const fullPath = path.join(current, entry.name);
|
|
249
|
+
if (entry.isDirectory()) {
|
|
250
|
+
stack.push(fullPath);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
255
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
256
|
+
result.push(fullPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result.sort();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function safeStat(p: string): fs.Stats | null {
|
|
265
|
+
try {
|
|
266
|
+
return fs.statSync(p);
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assertImageExists(imagePath: string): void {
|
|
273
|
+
if (!fs.existsSync(imagePath)) {
|
|
274
|
+
throw new Error(`File not found: ${imagePath}`);
|
|
275
|
+
}
|
|
276
|
+
if (!fs.statSync(imagePath).isFile()) {
|
|
277
|
+
throw new Error(`Not a file: ${imagePath}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function printUsage(): void {
|
|
282
|
+
console.log(`
|
|
283
|
+
pixmap — local image similarity search
|
|
284
|
+
|
|
285
|
+
Usage:
|
|
286
|
+
pixmap add <image|dir> [...] Add image(s) or directory to the index
|
|
287
|
+
pixmap search <image> [-k N] Find similar indexed images
|
|
288
|
+
pixmap show <image|id> Preview an image in the terminal
|
|
289
|
+
pixmap list Show all indexed images
|
|
290
|
+
pixmap status Show index stats
|
|
291
|
+
|
|
292
|
+
Options:
|
|
293
|
+
-d, --data-dir <path> Data directory (default: ./data)
|
|
294
|
+
-k, --top-k <number> Number of results (default: ${DEFAULT_TOP_K})
|
|
295
|
+
-h, --help Show this help
|
|
296
|
+
|
|
297
|
+
Examples:
|
|
298
|
+
pixmap add photo.jpg
|
|
299
|
+
pixmap add ./photos/
|
|
300
|
+
pixmap search query.png -k 10
|
|
301
|
+
pixmap show 3
|
|
302
|
+
pixmap list
|
|
303
|
+
`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
main().catch((error: unknown) => {
|
|
307
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
308
|
+
console.error(`Error: ${message}`);
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
});
|
package/src/indexer.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ImageEmbedder } from './embedder.js';
|
|
2
|
+
import { MetadataDb } from './metadataDb.js';
|
|
3
|
+
import { VectorStore } from './vectorStore.js';
|
|
4
|
+
|
|
5
|
+
export async function addImage(
|
|
6
|
+
imagePath: string,
|
|
7
|
+
embedder: ImageEmbedder,
|
|
8
|
+
store: VectorStore,
|
|
9
|
+
db: MetadataDb
|
|
10
|
+
): Promise<{ id: number; skipped: boolean }> {
|
|
11
|
+
const record = db.upsert(imagePath);
|
|
12
|
+
if (record.indexed) {
|
|
13
|
+
return { id: record.id, skipped: true };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const vector = await embedder.embed(imagePath);
|
|
17
|
+
store.add(record.id, vector);
|
|
18
|
+
db.markIndexed(record.id);
|
|
19
|
+
|
|
20
|
+
return { id: record.id, skipped: false };
|
|
21
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { ImageRecord } from './types.js';
|
|
3
|
+
|
|
4
|
+
export class MetadataDb {
|
|
5
|
+
private db: Database.Database;
|
|
6
|
+
|
|
7
|
+
constructor(path: string) {
|
|
8
|
+
this.db = new Database(path);
|
|
9
|
+
this.db.pragma('journal_mode = WAL');
|
|
10
|
+
this.migrateSchema();
|
|
11
|
+
this.db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS images (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
path TEXT NOT NULL UNIQUE,
|
|
15
|
+
indexed INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
created INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
17
|
+
)
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
upsert(path: string): { id: number; indexed: boolean } {
|
|
22
|
+
const existing = this.findByPath(path);
|
|
23
|
+
if (existing) {
|
|
24
|
+
return { id: existing.id, indexed: existing.indexed === 1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = this.db.prepare('INSERT INTO images (path, indexed) VALUES (?, 0)').run(path);
|
|
28
|
+
|
|
29
|
+
return { id: Number(result.lastInsertRowid), indexed: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
markIndexed(id: number): void {
|
|
33
|
+
this.db.prepare('UPDATE images SET indexed = 1 WHERE id = ?').run(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(id: number): ImageRecord | undefined {
|
|
37
|
+
return this.db.prepare('SELECT * FROM images WHERE id = ?').get(id) as ImageRecord | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
list(limit = 200): ImageRecord[] {
|
|
41
|
+
return this.db
|
|
42
|
+
.prepare('SELECT * FROM images WHERE indexed = 1 ORDER BY created DESC, id DESC LIMIT ?')
|
|
43
|
+
.all(limit) as ImageRecord[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
findByPath(path: string): ImageRecord | undefined {
|
|
47
|
+
return this.db.prepare('SELECT * FROM images WHERE path = ?').get(path) as ImageRecord | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private migrateSchema(): void {
|
|
51
|
+
const exists = this.db
|
|
52
|
+
.prepare(
|
|
53
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'images'"
|
|
54
|
+
)
|
|
55
|
+
.get() as { name: string } | undefined;
|
|
56
|
+
|
|
57
|
+
if (!exists) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const columns = this.db
|
|
62
|
+
.prepare("PRAGMA table_info('images')")
|
|
63
|
+
.all() as Array<{ name: string }>;
|
|
64
|
+
const allowed = new Set(['id', 'path', 'indexed', 'created']);
|
|
65
|
+
const isCurrentSchema =
|
|
66
|
+
columns.length === allowed.size && columns.every((c) => allowed.has(c.name));
|
|
67
|
+
|
|
68
|
+
if (isCurrentSchema) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.db.exec(`
|
|
73
|
+
BEGIN;
|
|
74
|
+
CREATE TABLE images_new (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
path TEXT NOT NULL UNIQUE,
|
|
77
|
+
indexed INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
created INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
79
|
+
);
|
|
80
|
+
INSERT INTO images_new (id, path, indexed, created)
|
|
81
|
+
SELECT id, path, indexed, created FROM images;
|
|
82
|
+
DROP TABLE images;
|
|
83
|
+
ALTER TABLE images_new RENAME TO images;
|
|
84
|
+
COMMIT;
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
}
|