Phase 4 - board templates, auto-backup, version history
This commit is contained in:
@@ -12,6 +12,7 @@ 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
|
||||
@@ -36,6 +37,16 @@ async function getSettingsPath(): Promise<string> {
|
||||
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`);
|
||||
}
|
||||
@@ -63,6 +74,16 @@ export async function ensureDataDirs(): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -124,6 +145,7 @@ export async function listBoards(): Promise<BoardMeta[]> {
|
||||
color: board.color,
|
||||
cardCount: Object.keys(board.cards).length,
|
||||
columnCount: board.columns.length,
|
||||
createdAt: board.createdAt,
|
||||
updatedAt: board.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
@@ -160,6 +182,14 @@ export async function saveBoard(board: Board): Promise<void> {
|
||||
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
|
||||
}
|
||||
@@ -274,6 +304,112 @@ export async function searchAllBoards(query: string): Promise<SearchResult[]> {
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user