Entity export and stuff
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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