From e9c0bf5c5237f0b0da0c38e74f4715fe27d0d413 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 27 Feb 2026 23:23:43 +0100 Subject: [PATCH] Entity export and stuff --- src/app/workspace/page.tsx | 75 +++++++- src/components/Router.tsx | 8 +- src/components/view/DocView.tsx | 82 ++++++++- src/components/view/TaskView.tsx | 78 +++++++- src/lib/export.ts | 298 +++++++++++++++++++++++++++++++ src/lib/import.ts | 194 ++++++++++++++++++++ 6 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 src/lib/export.ts create mode 100644 src/lib/import.ts diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index b848bc8..389f65a 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -15,26 +15,91 @@ import { export default function Workspace() { const [currentWorkspace, setCurrentWorkspace] = useState(); + const [isInitialized, setIsInitialized] = useState(false); const { workspace, loadWorkspace, updateEntity, setWorkspace } = useWorkspace(); const [route, setRoute] = useState({}); + /** + * Initialize workspace from cookie on mount + */ useEffect(() => { - setCurrentWorkspace(Cookies.get("workspace")); + const workspaceId = Cookies.get("workspace"); + setCurrentWorkspace(workspaceId); + setIsInitialized(true); }, []); + /** + * Load workspace when currentWorkspace changes. + * + * `loadWorkspace` is intentionally omitted from the dependency array + * because it is recreated on every render by the `useWorkspace` hook. + * Including it would cause this effect to run repeatedly and trigger + * the "maximum update depth exceeded" error. ESLint warning is disabled + * for this case. + */ useEffect(() => { + if (!isInitialized) return; + loadWorkspace(currentWorkspace); - if (currentWorkspace) Cookies.set("workspace", currentWorkspace); - else Cookies.remove("workspace"); - }, [currentWorkspace]); + if (currentWorkspace) { + Cookies.set("workspace", currentWorkspace); + } else { + Cookies.remove("workspace"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentWorkspace, isInitialized]); + + /** + * Load route (entity) from localStorage when workspace loads + */ + useEffect(() => { + if (!workspace) return; + + try { + const savedRoute = localStorage.getItem(`route_${workspace.id}`); + + if (savedRoute) { + const parsedRoute: Route = JSON.parse(savedRoute); + setRoute(parsedRoute); + } else { + // Default to first entity if no route saved + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + } catch (err) { + console.error("Failed to load route:", err); + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + }, [workspace?.id]); + + /** + * Save route whenever it changes + */ + useEffect(() => { + if (!workspace || !route.entity) return; + + try { + localStorage.setItem( + `route_${workspace.id}`, + JSON.stringify({ workspace: workspace.id, entity: route.entity }), + ); + } catch (err) { + console.error("Failed to save route:", err); + } + }, [route, workspace?.id]); if (!workspace) { return (
{ const w = createDefaultWorkspace(); saveWorkspaceToLocalStorage(w); diff --git a/src/components/Router.tsx b/src/components/Router.tsx index d2c1107..4fbd5da 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -87,6 +87,7 @@ export default function Router({ statuses={entity.config.statuses} setTags={setTags} setStatuses={setStatuses} + entityName={entity.name} /> ); } @@ -101,7 +102,12 @@ export default function Router({ return (
- +
); } diff --git a/src/components/view/DocView.tsx b/src/components/view/DocView.tsx index a99c029..eb4a107 100644 --- a/src/components/view/DocView.tsx +++ b/src/components/view/DocView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Header from "@editorjs/header"; import List from "@editorjs/list"; @@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter"; import Warning from "@editorjs/warning"; import InlineCode from "@editorjs/inline-code"; import { OutputData } from "@editorjs/editorjs"; +import { + downloadFile, + exportDocAsJson, + exportDocAsMarkdown, + exportDocAsHtml, +} from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; function toMarkdown(data: OutputData): string { return data.blocks @@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string { export default function DocView({ doc, setDoc, + docName = "Document", }: { doc: OutputData; setDoc: (data: OutputData) => void; + docName?: string; }) { const holderRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "markdown" | "html") => { + const docEntity = { + id: "", + type: "doc" as const, + name: docName, + content: doc, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + let content = ""; + let filename = `${docName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportDocAsJson(docEntity); + filename += ".json"; + break; + case "markdown": + content = exportDocAsMarkdown(docEntity); + filename += ".md"; + break; + case "html": + content = exportDocAsHtml(docEntity); + filename += ".html"; + break; + } + + downloadFile( + content, + filename, + format === "html" ? "text/html" : "text/plain", + ); + }; useEffect(() => { let editor: any; @@ -135,5 +187,31 @@ export default function DocView({ }; }, []); - return
; + return ( +
+
+ + + + + + handleExport("markdown")}> + Export as Markdown + + handleExport("html")}> + Export as HTML + + + handleExport("json")}> + Export as JSON + + + +
+
+
+ ); } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index 5eb6b0a..edca4d6 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -6,6 +6,22 @@ import BoardView from "./board/BoardView"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { TableView } from "./table/TableView"; import EditView from "./edit/EditView"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; +import { + downloadFile, + exportTodoAsJson, + exportTodoAsCsv, + exportTodoAsMarkdown, +} from "@/lib/export"; +import { useEffect, useState } from "react"; export type TaskViewProps = { tags: Record; @@ -25,6 +41,9 @@ export type TaskViewProps = { /** Create a new task with a status */ createTask: (statusId: string) => void; + + /** Entity name for export */ + entityName?: string; }; export default function TaskView({ @@ -35,9 +54,66 @@ export default function TaskView({ setTags, setStatuses, createTask, + entityName = "Tasks", }: TaskViewProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "csv" | "markdown") => { + const todoEntity = { + id: "", + type: "todo" as const, + name: entityName, + config: { statuses }, + tags, + tasks, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let content = ""; + let filename = `${entityName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportTodoAsJson(todoEntity); + filename += ".json"; + break; + case "csv": + content = exportTodoAsCsv(todoEntity); + filename += ".csv"; + break; + case "markdown": + content = exportTodoAsMarkdown(todoEntity); + filename += ".md"; + break; + } + + downloadFile(content, filename, "text/plain"); + }; return ( -
+
+
+ + + + + + handleExport("csv")}> + Export as CSV + + handleExport("markdown")}> + Export as Markdown + + + handleExport("json")}> + Export as JSON + + + +
Board diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..18a61ca --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,298 @@ +import { Workspace, DocEntity, TodoEntity, Entity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Convert EditorJS OutputData to Markdown + */ +export function docToMarkdown(content: OutputData): string { + return content.blocks + .map((block) => { + switch (block.type) { + case "header": + return `${"#".repeat((block.data as any).level)} ${(block.data as any).text}`; + + case "paragraph": + return `${(block.data as any).text}\n`; + + case "list": + if ((block.data as any).style === "ordered") { + return ((block.data as any).items as Array<{ content: string }>) + .map((item, i) => `${i + 1}. ${item.content}`) + .join("\n"); + } else { + return ((block.data as any).items as Array<{ content: string }>) + .map((item) => `- ${item.content}`) + .join("\n"); + } + + case "code": + return `\`\`\`\n${(block.data as any).code}\n\`\`\``; + + case "quote": + return `> ${(block.data as any).text}`; + + case "delimiter": + return `---`; + + case "image": + return `![${(block.data as any).caption || ""}](${(block.data as any).file.url})`; + + default: + return ""; + } + }) + .join("\n\n"); +} + +/** + * Convert EditorJS OutputData to HTML + */ +export function docToHtml(content: OutputData): string { + const htmlBlocks = content.blocks.map((block) => { + switch (block.type) { + case "header": + return `${(block.data as any).text}`; + + case "paragraph": + return `

${(block.data as any).text}

`; + + case "list": + const items = ((block.data as any).items as Array<{ content: string }>) + .map((item) => `
  • ${item.content}
  • `) + .join("\n"); + + if ((block.data as any).style === "ordered") { + return `
      \n${items}\n
    `; + } else { + return `
      \n${items}\n
    `; + } + + case "code": + return `
    ${(block.data as any).code}
    `; + + case "quote": + return `
    ${(block.data as any).text}
    `; + + case "delimiter": + return `
    `; + + case "image": + return `${(block.data as any).caption || `; + + default: + return ""; + } + }); + + return ` + + + + + + + +${htmlBlocks.join("\n")} + +`; +} + +/** + * Export a Doc entity as JSON + */ +export function exportDocAsJson(doc: DocEntity): string { + return JSON.stringify( + { + name: doc.name, + type: "doc", + content: doc.content, + createdAt: new Date(doc.createdAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Doc entity as Markdown + */ +export function exportDocAsMarkdown(doc: DocEntity): string { + const header = `# ${doc.name}\n\nCreated: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}\n\n---\n\n`; + const content = docToMarkdown(doc.content); + return header + content; +} + +/** + * Export a Doc entity as HTML + */ +export function exportDocAsHtml(doc: DocEntity): string { + const html = docToHtml(doc.content); + const headerHtml = `

    ${doc.name}

    Created: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}


    `; + + // Insert header before the first body element + return html + .replace("", `${doc.name}\n`) + .replace("", `\n${headerHtml}\n`); +} + +/** + * Export a Todo entity as JSON + */ +export function exportTodoAsJson(todo: TodoEntity): string { + return JSON.stringify( + { + name: todo.name, + type: "todo", + config: todo.config, + tags: todo.tags, + tasks: todo.tasks, + createdAt: new Date(todo.createdAt).toISOString(), + updatedAt: new Date(todo.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Todo entity as CSV + */ +export function exportTodoAsCsv(todo: TodoEntity): string { + const headers = [ + "Title", + "Description", + "Status", + "Tags", + "Created", + "Updated", + ]; + const rows = Object.values(todo.tasks).map((task) => [ + task.title, + task.description || "", + todo.config.statuses.find((s) => s.id === task.statusId)?.name || "", + task.tagIds.map((id) => todo.tags[id]?.name || "").join(";"), + new Date(task.createdAt).toISOString(), + new Date(task.updatedAt).toISOString(), + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), + ), + ].join("\n"); + + return csvContent; +} + +/** + * Export a Todo entity as Markdown + */ +export function exportTodoAsMarkdown(todo: TodoEntity): string { + let markdown = `# ${todo.name}\n\nCreated: ${new Date(todo.createdAt).toISOString()}\nUpdated: ${new Date(todo.updatedAt).toISOString()}\n\n`; + + // Group tasks by status + const tasksByStatus: Record = {}; + for (const status of todo.config.statuses) { + tasksByStatus[status.id] = {}; + } + + for (const [taskId, task] of Object.entries(todo.tasks)) { + const statusId = task.statusId; + if (!tasksByStatus[statusId]) { + tasksByStatus[statusId] = {}; + } + tasksByStatus[statusId][taskId] = task; + } + + // Add tasks by status + for (const status of todo.config.statuses) { + markdown += `## ${status.name}\n\n`; + const tasks = tasksByStatus[status.id]; + if (Object.keys(tasks).length === 0) { + markdown += "- (No tasks)\n\n"; + } else { + for (const task of Object.values(tasks)) { + const tags = task.tagIds + .map((id) => todo.tags[id]?.name || "") + .filter((n) => n) + .join(", "); + markdown += `- **${task.title}**${tags ? ` (${tags})` : ""}`; + if (task.description) { + markdown += `\n - ${task.description}`; + } + markdown += "\n"; + } + } + markdown += "\n"; + } + + return markdown; +} + +/** + * Export entire workspace as JSON + */ +export function exportWorkspaceAsJson(workspace: Workspace): string { + return JSON.stringify(workspace, null, 2); +} + +/** + * Trigger download of a file + */ +export function downloadFile( + content: string, + filename: string, + mimeType: string = "text/plain", +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Export any entity based on its type + */ +export function exportEntity( + entity: Entity, + format: "json" | "markdown" | "html" | "csv", +): string { + if (entity.type === "doc") { + switch (format) { + case "json": + return exportDocAsJson(entity); + case "markdown": + return exportDocAsMarkdown(entity); + case "html": + return exportDocAsHtml(entity); + default: + return exportDocAsJson(entity); + } + } else if (entity.type === "todo") { + switch (format) { + case "json": + return exportTodoAsJson(entity); + case "markdown": + return exportTodoAsMarkdown(entity); + case "csv": + return exportTodoAsCsv(entity); + default: + return exportTodoAsJson(entity); + } + } + + return ""; +} diff --git a/src/lib/import.ts b/src/lib/import.ts new file mode 100644 index 0000000..c000f1f --- /dev/null +++ b/src/lib/import.ts @@ -0,0 +1,194 @@ +import { Workspace, DocEntity, TodoEntity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Parse JSON file content back to Workspace + */ +export function importWorkspaceFromJson(jsonContent: string): Workspace { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.id !== "string" || + typeof parsed.name !== "string" + ) { + throw new Error("Invalid workspace JSON structure"); + } + + return parsed as Workspace; + } catch (err) { + throw new Error(`Failed to parse workspace JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to DocEntity + */ +export function importDocFromJson(jsonContent: string): DocEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "doc" || + typeof parsed.content !== "object" + ) { + throw new Error("Invalid doc JSON structure"); + } + + return parsed as DocEntity; + } catch (err) { + throw new Error(`Failed to parse doc JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to TodoEntity + */ +export function importTodoFromJson(jsonContent: string): TodoEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "todo" || + typeof parsed.config !== "object" + ) { + throw new Error("Invalid todo JSON structure"); + } + + return parsed as TodoEntity; + } catch (err) { + throw new Error(`Failed to parse todo JSON: ${err}`); + } +} + +/** + * Parse CSV file content to extract tasks + */ +export function importTodoFromCsv( + csvContent: string, + todoEntity: TodoEntity, +): { + tasks: Record; + errors: string[]; +} { + const lines = csvContent.trim().split("\n"); + const tasks: Record = {}; + const errors: string[] = []; + + if (lines.length < 2) { + errors.push("CSV file must have at least a header row"); + return { tasks, errors }; + } + + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const titleIndex = headers.indexOf("title"); + const descriptionIndex = headers.indexOf("description"); + const statusIndex = headers.indexOf("status"); + const tagsIndex = headers.indexOf("tags"); + + if (titleIndex === -1) { + errors.push("CSV must have a 'Title' column"); + return { tasks, errors }; + } + + for (let i = 1; i < lines.length; i++) { + try { + const line = lines[i]; + if (!line.trim()) continue; + + const values = line.split(",").map((v) => v.trim().replace(/^"|"$/g, "")); + + const title = values[titleIndex]; + if (!title) { + errors.push(`Row ${i + 1}: Missing title`); + continue; + } + + // Find status by name + let statusId = todoEntity.config.statuses[0].id; + if (statusIndex !== -1 && values[statusIndex]) { + const status = todoEntity.config.statuses.find( + (s) => s.name.toLowerCase() === values[statusIndex].toLowerCase(), + ); + if (status) { + statusId = status.id; + } + } + + // Parse tags + let tagIds: string[] = []; + if (tagsIndex !== -1 && values[tagsIndex]) { + const tagNames = values[tagsIndex].split(";").map((t) => t.trim()); + tagIds = tagNames + .map((name) => { + const tag = Object.entries(todoEntity.tags).find( + ([, t]) => t.name.toLowerCase() === name.toLowerCase(), + ); + return tag ? tag[0] : null; + }) + .filter((id) => id !== null) as string[]; + } + + const taskId = `task_${Math.random().toString(36).slice(2, 9)}`; + tasks[taskId] = { + id: taskId, + title, + description: descriptionIndex !== -1 ? values[descriptionIndex] : "", + statusId, + tagIds, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } catch (err) { + errors.push(`Row ${i + 1}: ${err}`); + } + } + + return { tasks, errors }; +} + +/** + * Open file picker and read file content + */ +export function openFileDialog( + accept: string = ".json", +): Promise<{ content: string; filename: string }> { + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + + input.onchange = (e: any) => { + const file = e.target.files?.[0]; + if (!file) { + reject(new Error("No file selected")); + return; + } + + const reader = new FileReader(); + reader.onload = (event: any) => { + try { + const content = event.target?.result as string; + resolve({ content, filename: file.name }); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }; + + input.click(); + }); +}