From 9c47ff659f4cdec411cd0ebc903244b0702993f8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:06:45 +0900 Subject: [PATCH] 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) --- src/main/services/MediaStore.ts | 37 ++++++++++++++++++++++++++++ tests/unit/MediaStore.test.ts | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/main/services/MediaStore.ts create mode 100644 tests/unit/MediaStore.test.ts diff --git a/src/main/services/MediaStore.ts b/src/main/services/MediaStore.ts new file mode 100644 index 0000000..2cc06f5 --- /dev/null +++ b/src/main/services/MediaStore.ts @@ -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 { + 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 { + await rm(join(this.profileDir, 'media', noteId), { recursive: true, force: true }); + } + + async listNoteDirs(): Promise { + 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; + } + } +} diff --git a/tests/unit/MediaStore.test.ts b/tests/unit/MediaStore.test.ts new file mode 100644 index 0000000..de67eae --- /dev/null +++ b/tests/unit/MediaStore.test.ts @@ -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']); + }); +});