feat: add filesystem persistence layer for boards and settings

This commit is contained in:
Your Name
2026-02-15 18:39:24 +02:00
parent 7818a446ca
commit 8b49f2afd1

292
src/lib/storage.ts Normal file
View File

@@ -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<string> {
const base = await appDataDir();
return join(base, "openpylon");
}
async function getBoardsDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "boards");
}
async function getAttachmentsDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "attachments");
}
async function getSettingsPath(): Promise<string> {
const base = await getBaseDir();
return join(base, "settings.json");
}
function boardFilePath(boardsDir: string, boardId: string): Promise<string> {
return join(boardsDir, `${boardId}.json`);
}
function boardBackupPath(boardsDir: string, boardId: string): Promise<string> {
return join(boardsDir, `${boardId}.backup.json`);
}
// ---------------------------------------------------------------------------
// Directory setup
// ---------------------------------------------------------------------------
export async function ensureDataDirs(): Promise<void> {
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<AppSettings> {
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<void> {
const settingsPath = await getSettingsPath();
await writeTextFile(settingsPath, JSON.stringify(settings, null, 2));
}
// ---------------------------------------------------------------------------
// Board listing
// ---------------------------------------------------------------------------
export async function listBoards(): Promise<BoardMeta[]> {
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<Board> {
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<void> {
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<void> {
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<Board> {
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<SearchResult[]> {
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<string> {
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;
}