feat: add import/export for JSON, CSV, and Trello formats

This commit is contained in:
Your Name
2026-02-15 19:14:06 +02:00
parent 491c089be6
commit e020ba6e8b
3 changed files with 370 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { BoardCard } from "@/components/boards/BoardCard";
import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
import { ImportExportButtons } from "@/components/import-export/ImportExportButtons";
export function BoardList() {
const boards = useAppStore((s) => s.boards);
@@ -27,10 +28,13 @@ export function BoardList() {
<p className="font-mono text-sm text-pylon-text-secondary">
Create your first board
</p>
<div className="flex items-center gap-2">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
New Board
</Button>
<ImportExportButtons />
</div>
</div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
@@ -45,11 +49,14 @@ export function BoardList() {
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
Your Boards
</h2>
<div className="flex items-center gap-2">
<ImportExportButtons />
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
New
</Button>
</div>
</div>
{/* Board grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">

View File

@@ -0,0 +1,125 @@
import { useRef } from "react";
import { Download, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
import { saveBoard } from "@/lib/storage";
import {
exportBoardAsJson,
exportBoardAsCsv,
importBoardFromJson,
importFromTrelloJson,
} from "@/lib/import-export";
function downloadBlob(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function ImportExportButtons() {
const fileInputRef = useRef<HTMLInputElement>(null);
const board = useBoardStore((s) => s.board);
const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard);
function handleExportJson() {
if (!board) return;
const json = exportBoardAsJson(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json");
}
function handleExportCsv() {
if (!board) return;
const csv = exportBoardAsCsv(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv");
}
function handleImportClick() {
fileInputRef.current?.click();
}
async function handleFileSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
let imported;
// Try to detect if it's a Trello export
const parsed = JSON.parse(text);
if (parsed.lists && parsed.cards && !parsed.columns) {
// Looks like Trello format
imported = importFromTrelloJson(text);
} else {
imported = importBoardFromJson(text);
}
await saveBoard(imported);
await refreshBoards();
await openBoard(imported.id);
setView({ type: "board", boardId: imported.id });
addRecentBoard(imported.id);
} catch (err) {
console.error("Import failed:", err);
// Could show a toast here in the future
}
// Reset the input so the same file can be re-imported
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
return (
<div className="flex gap-2">
{/* Import button */}
<Button variant="outline" size="sm" onClick={handleImportClick}>
<Upload className="size-4" />
Import
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelected}
className="hidden"
/>
{/* Export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={!board}>
<Download className="size-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExportJson}>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportCsv}>
Export as CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

230
src/lib/import-export.ts Normal file
View File

@@ -0,0 +1,230 @@
import { ulid } from "ulid";
import { boardSchema } from "@/lib/schemas";
import type { Board, Card, Column, Label, ChecklistItem } from "@/types/board";
// ---------------------------------------------------------------------------
// Export: JSON
// ---------------------------------------------------------------------------
export function exportBoardAsJson(board: Board): string {
return JSON.stringify(board, null, 2);
}
// ---------------------------------------------------------------------------
// Export: CSV
// ---------------------------------------------------------------------------
function escapeCsv(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function exportBoardAsCsv(board: Board): string {
const headers = [
"board",
"column",
"title",
"description",
"labels",
"dueDate",
"checklistTotal",
"checklistDone",
"createdAt",
"updatedAt",
];
const rows: string[] = [headers.join(",")];
for (const column of board.columns) {
for (const cardId of column.cardIds) {
const card = board.cards[cardId];
if (!card) continue;
const labelNames = card.labels
.map((lid) => board.labels.find((l) => l.id === lid)?.name ?? lid)
.join(";");
const checklistTotal = card.checklist.length;
const checklistDone = card.checklist.filter((item) => item.checked).length;
const row = [
escapeCsv(board.title),
escapeCsv(column.title),
escapeCsv(card.title),
escapeCsv(card.description),
escapeCsv(labelNames),
card.dueDate ?? "",
String(checklistTotal),
String(checklistDone),
card.createdAt,
card.updatedAt,
];
rows.push(row.join(","));
}
}
return rows.join("\n");
}
// ---------------------------------------------------------------------------
// Import: JSON (OpenPylon format)
// ---------------------------------------------------------------------------
export function importBoardFromJson(jsonString: string): Board {
const data = JSON.parse(jsonString);
const board = boardSchema.parse(data) as Board;
return board;
}
// ---------------------------------------------------------------------------
// Import: Trello JSON
// ---------------------------------------------------------------------------
interface TrelloCard {
name: string;
desc?: string;
due?: string | null;
labels?: { name: string; color: string }[];
idList: string;
closed?: boolean;
checklists?: {
name: string;
checkItems: { name: string; state: "complete" | "incomplete" }[];
}[];
}
interface TrelloList {
id: string;
name: string;
closed?: boolean;
}
interface TrelloBoard {
name: string;
lists?: TrelloList[];
cards?: TrelloCard[];
labels?: { name: string; color: string }[];
}
const TRELLO_COLOR_MAP: Record<string, string> = {
green: "#22c55e",
yellow: "#eab308",
orange: "#f97316",
red: "#ef4444",
purple: "#8b5cf6",
blue: "#3b82f6",
sky: "#0ea5e9",
lime: "#84cc16",
pink: "#ec4899",
black: "#1e293b",
};
export function importFromTrelloJson(jsonString: string): Board {
const data = JSON.parse(jsonString) as TrelloBoard;
const ts = new Date().toISOString();
const boardId = ulid();
// Map Trello labels
const labels: Label[] = [];
const trelloLabelMap = new Map<string, string>(); // trello label key -> our label id
if (data.labels) {
for (const tLabel of data.labels) {
if (!tLabel.name && !tLabel.color) continue;
const id = ulid();
const color = TRELLO_COLOR_MAP[tLabel.color] ?? "#6366f1";
labels.push({ id, name: tLabel.name || tLabel.color, color });
trelloLabelMap.set(`${tLabel.name}-${tLabel.color}`, id);
}
}
// Map Trello lists -> columns, filtering out archived lists
const openLists = (data.lists ?? []).filter((l) => !l.closed);
const listIdToColumnId = new Map<string, string>();
const columns: Column[] = openLists.map((list) => {
const colId = ulid();
listIdToColumnId.set(list.id, colId);
return {
id: colId,
title: list.name,
cardIds: [],
width: "standard" as const,
};
});
// Map Trello cards
const cards: Record<string, Card> = {};
for (const tCard of data.cards ?? []) {
if (tCard.closed) continue;
const columnId = listIdToColumnId.get(tCard.idList);
if (!columnId) continue;
const cardId = ulid();
// Map labels
const cardLabelIds: string[] = [];
if (tCard.labels) {
for (const tl of tCard.labels) {
const key = `${tl.name}-${tl.color}`;
const existingId = trelloLabelMap.get(key);
if (existingId) {
cardLabelIds.push(existingId);
}
}
}
// Map checklists
const checklist: ChecklistItem[] = [];
if (tCard.checklists) {
for (const cl of tCard.checklists) {
for (const item of cl.checkItems) {
checklist.push({
id: ulid(),
text: item.name,
checked: item.state === "complete",
});
}
}
}
const card: Card = {
id: cardId,
title: tCard.name,
description: tCard.desc ?? "",
labels: cardLabelIds,
checklist,
dueDate: tCard.due ?? null,
attachments: [],
createdAt: ts,
updatedAt: ts,
};
cards[cardId] = card;
// Add card to column
const col = columns.find((c) => c.id === columnId);
if (col) {
col.cardIds.push(cardId);
}
}
const board: Board = {
id: boardId,
title: data.name || "Imported Board",
color: "#6366f1",
createdAt: ts,
updatedAt: ts,
columns,
cards,
labels,
settings: { attachmentMode: "link" },
};
return board;
}