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