Entity export and stuff

This commit is contained in:
2026-02-27 23:23:43 +01:00
parent bcb9fdb027
commit e9c0bf5c52
6 changed files with 726 additions and 9 deletions
+70 -5
View File
@@ -15,26 +15,91 @@ import {
export default function Workspace() {
const [currentWorkspace, setCurrentWorkspace] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const { workspace, loadWorkspace, updateEntity, setWorkspace } =
useWorkspace();
const [route, setRoute] = useState<Route>({});
/**
* 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 (
<main
className="flex h-svh items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-slate-900 dark:to-slate-800"
className="flex h-svh items-center justify-center bg-linear-to-br from-blue-50 to-indigo-100 dark:from-slate-900 dark:to-slate-800"
onClick={() => {
const w = createDefaultWorkspace();
saveWorkspaceToLocalStorage(w);
+7 -1
View File
@@ -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 (
<div className="h-full w-3xl mx-auto bg-card rounded-2xl p-6 border">
<DocView key={entity.id} doc={entity.content} setDoc={setDoc} />
<DocView
key={entity.id}
doc={entity.content}
setDoc={setDoc}
docName={entity.name}
/>
</div>
);
}
+80 -2
View File
@@ -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<HTMLDivElement | null>(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 <div ref={holderRef} className="docs-view h-max w-full" />;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isExporting}>
<FileDown className="h-4 w-4 mr-2" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("markdown")}>
Export as Markdown
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("html")}>
Export as HTML
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleExport("json")}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div ref={holderRef} className="docs-view h-max w-full" />
</div>
);
}
+77 -1
View File
@@ -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<TagId, Tag>;
@@ -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 (
<div className="px-6 pt-4">
<div className="px-6 pt-4 flex flex-col gap-4">
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isExporting}>
<FileDown className="h-4 w-4 mr-2" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("csv")}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("markdown")}>
Export as Markdown
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleExport("json")}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tabs defaultValue="board" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="board">Board</TabsTrigger>
+298
View File
@@ -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 `<h${(block.data as any).level}>${(block.data as any).text}</h${(block.data as any).level}>`;
case "paragraph":
return `<p>${(block.data as any).text}</p>`;
case "list":
const items = ((block.data as any).items as Array<{ content: string }>)
.map((item) => `<li>${item.content}</li>`)
.join("\n");
if ((block.data as any).style === "ordered") {
return `<ol>\n${items}\n</ol>`;
} else {
return `<ul>\n${items}\n</ul>`;
}
case "code":
return `<pre><code>${(block.data as any).code}</code></pre>`;
case "quote":
return `<blockquote>${(block.data as any).text}</blockquote>`;
case "delimiter":
return `<hr />`;
case "image":
return `<img src="${(block.data as any).file.url}" alt="${(block.data as any).caption || ""}" />`;
default:
return "";
}
});
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; margin: 20px; }
h1, h2, h3 { margin-top: 20px; }
code { background-color: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre { background-color: #f0f0f0; padding: 10px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; padding-left: 10px; color: #666; }
</style>
</head>
<body>
${htmlBlocks.join("\n")}
</body>
</html>`;
}
/**
* 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 = `<h1>${doc.name}</h1><p>Created: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}</p><hr />`;
// Insert header before the first body element
return html
.replace("</head>", `<title>${doc.name}</title>\n</head>`)
.replace("<body>", `<body>\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<string, typeof todo.tasks> = {};
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 "";
}
+194
View File
@@ -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<string, any>;
errors: string[];
} {
const lines = csvContent.trim().split("\n");
const tasks: Record<string, any> = {};
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();
});
}