Files
openpylon/src/lib/storage.ts

429 lines
13 KiB
TypeScript

import {
exists,
mkdir,
readTextFile,
writeTextFile,
readDir,
remove,
copyFile,
} from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
import { invoke } from "@tauri-apps/api/core";
import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "@/types/board";
import type { AppSettings } from "@/types/settings";
import type { BoardTemplate } from "@/types/template";
// ---------------------------------------------------------------------------
// Path helpers - portable: all data lives next to the exe
// ---------------------------------------------------------------------------
async function getBaseDir(): Promise<string> {
return invoke<string>("get_portable_data_dir");
}
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");
}
async function getTemplatesDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "templates");
}
async function getBackupsDir(boardId: string): Promise<string> {
const base = await getBaseDir();
return join(base, "backups", boardId);
}
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 });
}
const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
await mkdir(templatesDir, { recursive: true });
}
const backupsDir = await join(base, "backups");
if (!(await exists(backupsDir))) {
await mkdir(backupsDir, { 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,
createdAt: board.createdAt,
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);
// Create timestamped backup (throttled: only if last backup > 5 min ago)
const backups = await listBackups(board.id);
const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
await createBackup(JSON.parse(previous) as Board);
await pruneBackups(board.id);
}
} 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
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
export async function listTemplates(): Promise<BoardTemplate[]> {
const dir = await getTemplatesDir();
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const templates: BoardTemplate[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
templates.push(JSON.parse(raw));
} catch { continue; }
}
return templates;
}
export async function saveTemplate(template: BoardTemplate): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${template.id}.json`);
await writeTextFile(filePath, JSON.stringify(template, null, 2));
}
export async function deleteTemplate(templateId: string): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${templateId}.json`);
if (await exists(filePath)) {
await remove(filePath);
}
}
// ---------------------------------------------------------------------------
// Backups / Version History
// ---------------------------------------------------------------------------
export interface BackupEntry {
filename: string;
timestamp: string;
cardCount: number;
columnCount: number;
}
export async function listBackups(boardId: string): Promise<BackupEntry[]> {
const dir = await getBackupsDir(boardId);
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const backups: BackupEntry[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
const board = boardSchema.parse(data);
const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d.]+Z/);
backups.push({
filename: entry.name,
timestamp: isoMatch ? isoMatch[0].replace(/-(?=\d{2}-\d{2}T)/g, "-") : board.updatedAt,
cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length,
});
} catch { continue; }
}
backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
return backups;
}
export async function createBackup(board: Board): Promise<void> {
const dir = await getBackupsDir(board.id);
if (!(await exists(dir))) {
await mkdir(dir, { recursive: true });
}
const ts = new Date().toISOString().replace(/:/g, "-");
const filename = `${board.id}-${ts}.json`;
const filePath = await join(dir, filename);
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
export async function pruneBackups(boardId: string, keep: number = 10): Promise<void> {
const backups = await listBackups(boardId);
if (backups.length <= keep) return;
const dir = await getBackupsDir(boardId);
const toDelete = backups.slice(keep);
for (const backup of toDelete) {
try {
const filePath = await join(dir, backup.filename);
await remove(filePath);
} catch { /* skip */ }
}
}
export async function restoreBackupFile(boardId: string, filename: string): Promise<Board> {
const dir = await getBackupsDir(boardId);
const filePath = await join(dir, filename);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
return boardSchema.parse(data) as Board;
}
// ---------------------------------------------------------------------------
// 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;
}