feat: add import/export for JSON, CSV, and Trello formats
This commit is contained in:
@@ -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">
|
||||
|
||||
125
src/components/import-export/ImportExportButtons.tsx
Normal file
125
src/components/import-export/ImportExportButtons.tsx
Normal 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
230
src/lib/import-export.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user