429 lines
13 KiB
TypeScript
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;
|
|
}
|