feat: add filesystem persistence layer for boards and settings
This commit is contained in:
292
src/lib/storage.ts
Normal file
292
src/lib/storage.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user