Entity export and stuff
This commit is contained in:
@@ -15,26 +15,91 @@ import {
|
|||||||
|
|
||||||
export default function Workspace() {
|
export default function Workspace() {
|
||||||
const [currentWorkspace, setCurrentWorkspace] = useState<string>();
|
const [currentWorkspace, setCurrentWorkspace] = useState<string>();
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
const { workspace, loadWorkspace, updateEntity, setWorkspace } =
|
const { workspace, loadWorkspace, updateEntity, setWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
|
|
||||||
const [route, setRoute] = useState<Route>({});
|
const [route, setRoute] = useState<Route>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize workspace from cookie on mount
|
||||||
|
*/
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
|
||||||
loadWorkspace(currentWorkspace);
|
loadWorkspace(currentWorkspace);
|
||||||
if (currentWorkspace) Cookies.set("workspace", currentWorkspace);
|
if (currentWorkspace) {
|
||||||
else Cookies.remove("workspace");
|
Cookies.set("workspace", currentWorkspace);
|
||||||
}, [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) {
|
if (!workspace) {
|
||||||
return (
|
return (
|
||||||
<main
|
<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={() => {
|
onClick={() => {
|
||||||
const w = createDefaultWorkspace();
|
const w = createDefaultWorkspace();
|
||||||
saveWorkspaceToLocalStorage(w);
|
saveWorkspaceToLocalStorage(w);
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function Router({
|
|||||||
statuses={entity.config.statuses}
|
statuses={entity.config.statuses}
|
||||||
setTags={setTags}
|
setTags={setTags}
|
||||||
setStatuses={setStatuses}
|
setStatuses={setStatuses}
|
||||||
|
entityName={entity.name}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,12 @@ export default function Router({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-3xl mx-auto bg-card rounded-2xl p-6 border">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import Header from "@editorjs/header";
|
import Header from "@editorjs/header";
|
||||||
import List from "@editorjs/list";
|
import List from "@editorjs/list";
|
||||||
@@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter";
|
|||||||
import Warning from "@editorjs/warning";
|
import Warning from "@editorjs/warning";
|
||||||
import InlineCode from "@editorjs/inline-code";
|
import InlineCode from "@editorjs/inline-code";
|
||||||
import { OutputData } from "@editorjs/editorjs";
|
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 {
|
function toMarkdown(data: OutputData): string {
|
||||||
return data.blocks
|
return data.blocks
|
||||||
@@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string {
|
|||||||
export default function DocView({
|
export default function DocView({
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
|
docName = "Document",
|
||||||
}: {
|
}: {
|
||||||
doc: OutputData;
|
doc: OutputData;
|
||||||
setDoc: (data: OutputData) => void;
|
setDoc: (data: OutputData) => void;
|
||||||
|
docName?: string;
|
||||||
}) {
|
}) {
|
||||||
const holderRef = useRef<HTMLDivElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let editor: any;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ import BoardView from "./board/BoardView";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { TableView } from "./table/TableView";
|
import { TableView } from "./table/TableView";
|
||||||
import EditView from "./edit/EditView";
|
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 = {
|
export type TaskViewProps = {
|
||||||
tags: Record<TagId, Tag>;
|
tags: Record<TagId, Tag>;
|
||||||
@@ -25,6 +41,9 @@ export type TaskViewProps = {
|
|||||||
|
|
||||||
/** Create a new task with a status */
|
/** Create a new task with a status */
|
||||||
createTask: (statusId: string) => void;
|
createTask: (statusId: string) => void;
|
||||||
|
|
||||||
|
/** Entity name for export */
|
||||||
|
entityName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TaskView({
|
export default function TaskView({
|
||||||
@@ -35,9 +54,66 @@ export default function TaskView({
|
|||||||
setTags,
|
setTags,
|
||||||
setStatuses,
|
setStatuses,
|
||||||
createTask,
|
createTask,
|
||||||
|
entityName = "Tasks",
|
||||||
}: TaskViewProps) {
|
}: 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 (
|
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">
|
<Tabs defaultValue="board" className="w-full">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
<TabsTrigger value="board">Board</TabsTrigger>
|
<TabsTrigger value="board">Board</TabsTrigger>
|
||||||
|
|||||||
@@ -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 `.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 "";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user