feat(media): MediaStore for image persistence and cleanup
Task 8 of the slice plan. Saves clipboard PNG/JPEG bytes under
{profileDir}/media/{noteId}/{uuid}.{ext} with mkdir -p semantics,
returns relPath/mime/bytes for the repository row, supports
deleteNoteDirectory (used by note delete + GC) and listNoteDirs
(used by media GC to find orphans).
Verification: `npx vitest run tests/unit/MediaStore.test.ts`
4 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
src/main/services/MediaStore.ts
Normal file
37
src/main/services/MediaStore.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { mkdir, writeFile, rm, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface SavedMedia {
|
||||
relPath: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export class MediaStore {
|
||||
constructor(private profileDir: string) {}
|
||||
|
||||
async saveImage(noteId: string, bytes: Buffer, mime: string): Promise<SavedMedia> {
|
||||
const dir = join(this.profileDir, 'media', noteId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'bin';
|
||||
const filename = `${uuidv4()}.${ext}`;
|
||||
await writeFile(join(dir, filename), bytes);
|
||||
return { relPath: `media/${noteId}/${filename}`, mime, bytes: bytes.length };
|
||||
}
|
||||
|
||||
async deleteNoteDirectory(noteId: string): Promise<void> {
|
||||
await rm(join(this.profileDir, 'media', noteId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async listNoteDirs(): Promise<string[]> {
|
||||
const root = join(this.profileDir, 'media');
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
tests/unit/MediaStore.test.ts
Normal file
43
tests/unit/MediaStore.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
|
||||
describe('MediaStore', () => {
|
||||
let tmp: string;
|
||||
let store: MediaStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-media-'));
|
||||
store = new MediaStore(tmp);
|
||||
});
|
||||
|
||||
it('saves a png under media/{noteId}/', async () => {
|
||||
const bytes = Buffer.from('\x89PNG\r\n\x1a\n' + 'A'.repeat(100));
|
||||
const saved = await store.saveImage('note-123', bytes, 'image/png');
|
||||
expect(saved.relPath.startsWith('media/note-123/')).toBe(true);
|
||||
expect(saved.relPath.endsWith('.png')).toBe(true);
|
||||
expect(saved.bytes).toBe(bytes.length);
|
||||
expect(readFileSync(join(tmp, saved.relPath)).equals(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it('saves multiple images with unique filenames', async () => {
|
||||
const bytes = Buffer.from('abc');
|
||||
const a = await store.saveImage('n', bytes, 'image/png');
|
||||
const b = await store.saveImage('n', bytes, 'image/png');
|
||||
expect(a.relPath).not.toBe(b.relPath);
|
||||
});
|
||||
|
||||
it('deleteNoteDirectory removes the note dir', async () => {
|
||||
await store.saveImage('note-x', Buffer.from('abc'), 'image/png');
|
||||
await store.deleteNoteDirectory('note-x');
|
||||
expect(existsSync(join(tmp, 'media/note-x'))).toBe(false);
|
||||
});
|
||||
|
||||
it('listNoteDirs returns dir names', async () => {
|
||||
await store.saveImage('alpha', Buffer.from('a'), 'image/png');
|
||||
await store.saveImage('beta', Buffer.from('b'), 'image/png');
|
||||
expect((await store.listNoteDirs()).sort()).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user