From 8b49f2afd1e3fe67718d9b0fe1875ed5c558ca6e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 18:39:24 +0200 Subject: [PATCH] feat: add filesystem persistence layer for boards and settings --- src/lib/storage.ts | 292 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/lib/storage.ts diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..a650365 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,292 @@ +import { + exists, + mkdir, + readTextFile, + writeTextFile, + readDir, + remove, + copyFile, +} from "@tauri-apps/plugin-fs"; +import { appDataDir, join } from "@tauri-apps/api/path"; +import { boardSchema, appSettingsSchema } from "./schemas"; +import type { Board, BoardMeta } from "@/types/board"; +import type { AppSettings } from "@/types/settings"; + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +async function getBaseDir(): Promise { + const base = await appDataDir(); + return join(base, "openpylon"); +} + +async function getBoardsDir(): Promise { + const base = await getBaseDir(); + return join(base, "boards"); +} + +async function getAttachmentsDir(): Promise { + const base = await getBaseDir(); + return join(base, "attachments"); +} + +async function getSettingsPath(): Promise { + const base = await getBaseDir(); + return join(base, "settings.json"); +} + +function boardFilePath(boardsDir: string, boardId: string): Promise { + return join(boardsDir, `${boardId}.json`); +} + +function boardBackupPath(boardsDir: string, boardId: string): Promise { + return join(boardsDir, `${boardId}.backup.json`); +} + +// --------------------------------------------------------------------------- +// Directory setup +// --------------------------------------------------------------------------- + +export async function ensureDataDirs(): Promise { + const base = await getBaseDir(); + if (!(await exists(base))) { + await mkdir(base, { recursive: true }); + } + + const boardsDir = await getBoardsDir(); + if (!(await exists(boardsDir))) { + await mkdir(boardsDir, { recursive: true }); + } + + const attachmentsDir = await getAttachmentsDir(); + if (!(await exists(attachmentsDir))) { + await mkdir(attachmentsDir, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export async function loadSettings(): Promise { + const settingsPath = await getSettingsPath(); + + if (!(await exists(settingsPath))) { + const defaults = appSettingsSchema.parse({}); + return defaults; + } + + try { + const raw = await readTextFile(settingsPath); + const data = JSON.parse(raw); + return appSettingsSchema.parse(data); + } catch { + // If the file is corrupted, return defaults + return appSettingsSchema.parse({}); + } +} + +export async function saveSettings(settings: AppSettings): Promise { + const settingsPath = await getSettingsPath(); + await writeTextFile(settingsPath, JSON.stringify(settings, null, 2)); +} + +// --------------------------------------------------------------------------- +// Board listing +// --------------------------------------------------------------------------- + +export async function listBoards(): Promise { + const boardsDir = await getBoardsDir(); + + if (!(await exists(boardsDir))) { + return []; + } + + const entries = await readDir(boardsDir); + const metas: BoardMeta[] = []; + + for (const entry of entries) { + // DirEntry has `name` property in Tauri v2 + if (!entry.name || !entry.name.endsWith(".json") || entry.name.endsWith(".backup.json")) { + continue; + } + + try { + const filePath = await join(boardsDir, entry.name); + const raw = await readTextFile(filePath); + const data = JSON.parse(raw); + const board = boardSchema.parse(data); + + metas.push({ + id: board.id, + title: board.title, + color: board.color, + cardCount: Object.keys(board.cards).length, + columnCount: board.columns.length, + updatedAt: board.updatedAt, + }); + } catch { + // Skip corrupted board files + continue; + } + } + + // Sort by updatedAt descending (most recent first) + metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return metas; +} + +// --------------------------------------------------------------------------- +// Board CRUD +// --------------------------------------------------------------------------- + +export async function loadBoard(boardId: string): Promise { + const boardsDir = await getBoardsDir(); + const filePath = await boardFilePath(boardsDir, boardId); + + const raw = await readTextFile(filePath); + const data = JSON.parse(raw); + return boardSchema.parse(data) as Board; +} + +export async function saveBoard(board: Board): Promise { + const boardsDir = await getBoardsDir(); + const filePath = await boardFilePath(boardsDir, board.id); + const backupPath = await boardBackupPath(boardsDir, board.id); + + // Rotate previous version to backup + if (await exists(filePath)) { + try { + const previous = await readTextFile(filePath); + await writeTextFile(backupPath, previous); + } catch { + // If we can't create a backup, continue saving anyway + } + } + + await writeTextFile(filePath, JSON.stringify(board, null, 2)); +} + +export async function deleteBoard(boardId: string): Promise { + const boardsDir = await getBoardsDir(); + const filePath = await boardFilePath(boardsDir, boardId); + const backupPath = await boardBackupPath(boardsDir, boardId); + + // Remove board file + if (await exists(filePath)) { + await remove(filePath); + } + + // Remove backup file + if (await exists(backupPath)) { + await remove(backupPath); + } + + // Remove board attachments directory + const attachmentsDir = await getAttachmentsDir(); + const boardAttachmentsDir = await join(attachmentsDir, boardId); + if (await exists(boardAttachmentsDir)) { + await remove(boardAttachmentsDir, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Backup restoration +// --------------------------------------------------------------------------- + +export async function restoreFromBackup(boardId: string): Promise { + const boardsDir = await getBoardsDir(); + const backupPath = await boardBackupPath(boardsDir, boardId); + + const raw = await readTextFile(backupPath); + const data = JSON.parse(raw); + const board = boardSchema.parse(data) as Board; + + // Save the restored board as the current version + await saveBoard(board); + + return board; +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +export interface SearchResult { + boardId: string; + boardTitle: string; + cardId: string; + cardTitle: string; + matchField: "title" | "description"; +} + +export async function searchAllBoards(query: string): Promise { + const boardsDir = await getBoardsDir(); + + if (!(await exists(boardsDir))) { + return []; + } + + const entries = await readDir(boardsDir); + const results: SearchResult[] = []; + const lowerQuery = query.toLowerCase(); + + for (const entry of entries) { + if (!entry.name || !entry.name.endsWith(".json") || entry.name.endsWith(".backup.json")) { + continue; + } + + try { + const filePath = await join(boardsDir, entry.name); + const raw = await readTextFile(filePath); + const data = JSON.parse(raw); + const board = boardSchema.parse(data); + + for (const card of Object.values(board.cards)) { + if (card.title.toLowerCase().includes(lowerQuery)) { + results.push({ + boardId: board.id, + boardTitle: board.title, + cardId: card.id, + cardTitle: card.title, + matchField: "title", + }); + } else if (card.description.toLowerCase().includes(lowerQuery)) { + results.push({ + boardId: board.id, + boardTitle: board.title, + cardId: card.id, + cardTitle: card.title, + matchField: "description", + }); + } + } + } catch { + continue; + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Attachment helpers +// --------------------------------------------------------------------------- + +export async function copyAttachment( + boardId: string, + sourcePath: string, + fileName: string, +): Promise { + const attachmentsDir = await getAttachmentsDir(); + const boardAttDir = await join(attachmentsDir, boardId); + + if (!(await exists(boardAttDir))) { + await mkdir(boardAttDir, { recursive: true }); + } + + const destPath = await join(boardAttDir, fileName); + await copyFile(sourcePath, destPath); + return destPath; +}