commit b7759d654258945f8e081daa58325a683e5506e4 Author: Team3 Date: Fri May 22 00:01:59 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74de0aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +main.js +*.log +.DS_Store diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..245c469 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,40 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const ctx = await esbuild.context({ + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2020", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await ctx.rebuild(); + process.exit(0); +} else { + await ctx.watch(); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..26cfd9f --- /dev/null +++ b/main.ts @@ -0,0 +1,116 @@ +import { MarkdownView, Platform, Plugin, WorkspaceLeaf } from "obsidian"; +import { + VIEW_TYPE_PROJECT_VIEW, + VIEW_TYPE_PROJECT_DETAILS_VIEW, + RIBBON_ICON, +} from "./src/const"; +import { ProjectView } from "./src/views/ProjectView"; +import { ProjectDetailsView } from "./src/views/ProjectDetailsView"; +import { parseProjectFilePath, projectPath, nodeMdPath } from "./src/fs"; +import { BreadcrumbSegment, injectMobileBreadcrumb, openMarkdown } from "./src/ui"; +import { normalizePath } from "obsidian"; + +export default class ProjektkontextPlugin extends Plugin { + async onload(): Promise { + this.registerView(VIEW_TYPE_PROJECT_VIEW, (leaf) => new ProjectView(leaf)); + this.registerView(VIEW_TYPE_PROJECT_DETAILS_VIEW, (leaf) => new ProjectDetailsView(leaf)); + + this.registerObsidianProtocolHandler("obsidian-graph", async (params) => { + const project = params.project; + if (!project) { + await this.activateProjectView(); + return; + } + const leaf = this.app.workspace.getLeaf(false); + await leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { project }, + }); + this.app.workspace.revealLeaf(leaf); + }); + + this.addRibbonIcon(RIBBON_ICON, "Projekte", () => { + void this.activateProjectView(); + }); + + this.addCommand({ + id: "open-projects", + name: "Projekte öffnen", + callback: () => void this.activateProjectView(), + }); + + if (Platform.isMobile) { + const reattach = () => this.reattachMobileBreadcrumbs(); + this.registerEvent( + this.app.workspace.on("file-open", () => requestAnimationFrame(reattach)), + ); + this.registerEvent( + this.app.workspace.on("active-leaf-change", () => requestAnimationFrame(reattach)), + ); + this.registerEvent(this.app.workspace.on("layout-change", reattach)); + } + } + + async onunload(): Promise {} + + async activateProjectView(): Promise { + const { workspace } = this.app; + let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_PROJECT_VIEW)[0] ?? null; + if (!leaf) { + leaf = workspace.getLeaf(false); + await leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }); + } + workspace.revealLeaf(leaf); + } + + private reattachMobileBreadcrumbs(): void { + this.app.workspace.iterateRootLeaves((leaf) => this.applyMobileBreadcrumb(leaf)); + } + + private applyMobileBreadcrumb(leaf: WorkspaceLeaf): void { + const view = leaf.view; + if (!(view instanceof MarkdownView)) return; + const file = view.file; + if (!file) { + injectMobileBreadcrumb(view, []); + return; + } + const loc = parseProjectFilePath(file.path); + if (!loc) { + injectMobileBreadcrumb(view, []); + return; + } + const segments: BreadcrumbSegment[] = [ + { + label: "Projekte", + onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }), + }, + { + label: loc.project, + onClick: () => + void leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { project: loc.project }, + }), + }, + ]; + let folder = projectPath(loc.project); + const last = loc.path[loc.path.length - 1] ?? ""; + const isNodeMd = loc.path.length >= 2 && last.endsWith(".md") && + last.replace(/\.md$/, "") === loc.path[loc.path.length - 2]; + const ancestorCount = isNodeMd ? loc.path.length - 2 : loc.path.length - 1; + for (let i = 0; i < ancestorCount; i++) { + const name = loc.path[i]; + folder = normalizePath(`${folder}/${name}`); + const md = nodeMdPath(folder); + segments.push({ + label: name, + onClick: () => void openMarkdown(this.app, md, leaf), + }); + } + segments.push({ label: file.basename }); + injectMobileBreadcrumb(view, segments); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9034dd8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "obsidian-graph", + "name": "Obsidian Graph", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Projekte als hierarchischen Graph (Mindmap) strukturieren.", + "author": "marha", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2c437c5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,658 @@ +{ + "name": "obsidian-graph", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-graph", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@types/cytoscape": "^3.21.9", + "cytoscape": "^3.33.4", + "cytoscape-fcose": "^2.2.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^3.3.0", + "esbuild": "0.20.2", + "obsidian": "^1.5.7", + "tslib": "2.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/cytoscape": { + "version": "3.21.9", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz", + "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cytoscape": { + "version": "3.33.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", + "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..60bd159 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "obsidian-graph", + "version": "0.1.0", + "description": "Obsidian Plugin - Projekte als hierarchischen Graph", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "keywords": [], + "author": "marha", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^3.3.0", + "esbuild": "0.20.2", + "obsidian": "^1.5.7", + "tslib": "2.6.2", + "typescript": "5.4.5" + }, + "dependencies": { + "@types/cytoscape": "^3.21.9", + "cytoscape": "^3.33.4", + "cytoscape-fcose": "^2.2.0" + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..5ea6e75 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,11 @@ +export const PROJECTS_ROOT = ""; + +export const VIEW_TYPE_PROJECT_VIEW = "obsidian-graph-projects"; +export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "obsidian-graph-overview"; + +export const RIBBON_ICON = "layout-grid"; + +export const CORE_FILE = "_core.md"; +export const DESCRIPTION_FILE = "_description.md"; + +export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const; diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..7bb68a3 --- /dev/null +++ b/src/fs.ts @@ -0,0 +1,160 @@ +import { App, TFile, TFolder, normalizePath } from "obsidian"; +import { PROJECTS_ROOT, PROJECT_FILES } from "./const"; + +export function projectsPath(): string { + return PROJECTS_ROOT; +} + +export function projectPath(name: string): string { + return normalizePath(`${PROJECTS_ROOT}/${name}`); +} + +export function nodeMdPath(folderPath: string): string { + const p = normalizePath(folderPath); + const name = p.split("/").pop() ?? p; + return normalizePath(`${p}/${name}.md`); +} + +export function isNode(app: App, folderPath: string): boolean { + const md = app.vault.getAbstractFileByPath(nodeMdPath(folderPath)); + return md instanceof TFile; +} + +export async function ensureFolder(app: App, path: string): Promise { + if (!path) return; + const p = normalizePath(path); + const exists = app.vault.getAbstractFileByPath(p); + if (!exists) { + await app.vault.createFolder(p); + } +} + +export async function ensureFile(app: App, path: string, content = ""): Promise { + const p = normalizePath(path); + const existing = app.vault.getAbstractFileByPath(p); + if (existing instanceof TFile) return existing; + return await app.vault.create(p, content); +} + +export function listFolders(app: App, path: string): TFolder[] { + const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot(); + if (!(folder instanceof TFolder)) return []; + return folder.children + .filter((c): c is TFolder => c instanceof TFolder) + .filter((c) => !c.name.startsWith("_") || c.name.startsWith("__")) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] { + const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot(); + if (!(folder instanceof TFolder)) return []; + return folder.children + .filter((c): c is TFile => c instanceof TFile && c.extension === "md") + .filter((f) => !exclude.includes(f.name)) + .filter((f) => !f.basename.startsWith("_") || f.basename.startsWith("__")) + .sort((a, b) => a.basename.localeCompare(b.basename)); +} + +export async function readFile(app: App, path: string): Promise { + const p = normalizePath(path); + const f = app.vault.getAbstractFileByPath(p); + if (!(f instanceof TFile)) return ""; + return await app.vault.read(f); +} + +export async function deleteRecursive(app: App, path: string): Promise { + const p = normalizePath(path); + const f = app.vault.getAbstractFileByPath(p); + if (!f) return; + await app.vault.delete(f, true); +} + +export async function rename(app: App, oldPath: string, newPath: string): Promise { + const f = app.vault.getAbstractFileByPath(normalizePath(oldPath)); + if (!f) return; + await app.fileManager.renameFile(f, normalizePath(newPath)); +} + +export async function createProject(app: App, name: string): Promise { + const root = projectPath(name); + await ensureFolder(app, projectsPath()); + await ensureFolder(app, root); + for (const file of PROJECT_FILES) { + await ensureFile(app, normalizePath(`${root}/${file}`), ""); + } +} + +export async function createNode( + app: App, + parentFolderPath: string, + name: string, +): Promise { + const folder = normalizePath(`${parentFolderPath}/${name}`); + await ensureFolder(app, folder); + return await ensureFile(app, nodeMdPath(folder), ""); +} + +export async function renameNode( + app: App, + folderPath: string, + newName: string, +): Promise { + const p = normalizePath(folderPath); + const parent = p.substring(0, p.lastIndexOf("/")); + const oldName = p.split("/").pop() ?? ""; + if (oldName === newName) return; + const oldMd = nodeMdPath(p); + const newFolder = normalizePath(`${parent}/${newName}`); + const mdFile = app.vault.getAbstractFileByPath(oldMd); + if (mdFile instanceof TFile) { + const tmpMd = normalizePath(`${p}/${newName}.md`); + if (tmpMd !== oldMd) await rename(app, oldMd, tmpMd); + } + await rename(app, p, newFolder); +} + +export async function moveNode( + app: App, + folderPath: string, + newParentFolderPath: string, +): Promise { + const p = normalizePath(folderPath); + const name = p.split("/").pop() ?? ""; + const target = normalizePath(`${newParentFolderPath}/${name}`); + if (p === target) return; + await rename(app, p, target); +} + +export interface NodeTree { + name: string; + path: string; + children: NodeTree[]; +} + +export function buildNodeTree(app: App, projectName: string): NodeTree { + const root = projectPath(projectName); + const walk = (folderPath: string, name: string): NodeTree => { + const children = listFolders(app, folderPath).map((f) => walk(f.path, f.name)); + return { name, path: folderPath, children }; + }; + return walk(root, projectName); +} + +export interface ProjectFileLocation { + project: string; + path: string[]; +} + +export function parseProjectFilePath(path: string): ProjectFileLocation | null { + if (!path.endsWith(".md")) return null; + const parts = normalizePath(path).split("/"); + let idx = 0; + if (PROJECTS_ROOT) { + if (parts[0] !== PROJECTS_ROOT) return null; + idx = 1; + } + if (parts.length < idx + 2) return null; + const project = parts[idx]; + const rest = parts.slice(idx + 1); + return { project, path: rest }; +} diff --git a/src/modals/NameModal.ts b/src/modals/NameModal.ts new file mode 100644 index 0000000..e7c6ba8 --- /dev/null +++ b/src/modals/NameModal.ts @@ -0,0 +1,68 @@ +import { App, Modal, Setting } from "obsidian"; + +export interface NameModalOptions { + title: string; + label?: string; + initial?: string; + cta: string; + validate?: (name: string) => string | null; + onSubmit: (name: string) => void | Promise; +} + +export class NameModal extends Modal { + private opts: NameModalOptions; + private value: string; + + constructor(app: App, opts: NameModalOptions) { + super(app); + this.opts = opts; + this.value = opts.initial ?? ""; + } + + onOpen(): void { + this.titleEl.setText(this.opts.title); + const setting = new Setting(this.contentEl) + .setName(this.opts.label ?? "Name") + .addText((t) => + t.setValue(this.value).onChange((v) => { + this.value = v; + }), + ); + const input = setting.controlEl.querySelector("input"); + input?.focus(); + input?.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + this.submit(); + } + }); + new Setting(this.contentEl) + .addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close())) + .addButton((b) => + b + .setButtonText(this.opts.cta) + .setCta() + .onClick(() => this.submit()), + ); + } + + private async submit(): Promise { + const name = this.value.trim(); + if (!name) return; + if (this.opts.validate) { + const err = this.opts.validate(name); + if (err) { + const existing = this.contentEl.querySelector(".pk-modal-error"); + if (existing) existing.remove(); + this.contentEl.createDiv({ cls: "pk-modal-error", text: err }); + return; + } + } + await this.opts.onSubmit(name); + this.close(); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..7074491 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,125 @@ +import { App, Menu, Platform, TFile, View, WorkspaceLeaf, normalizePath } from "obsidian"; + +export interface CardOptions { + title: string; + body?: string | ((el: HTMLElement) => void); + cls?: string; + onClick?: (ev: MouseEvent) => void; + onContextMenu?: (ev: MouseEvent) => void; +} + +export function card(parent: HTMLElement, opts: CardOptions): HTMLElement { + const el = parent.createDiv({ cls: `pk-card ${opts.cls ?? ""}`.trim() }); + const header = el.createDiv({ cls: "pk-card-header", text: opts.title }); + if (opts.onClick) { + header.addEventListener("click", opts.onClick); + header.addClass("pk-clickable"); + } + if (opts.onContextMenu) { + el.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + opts.onContextMenu!(ev); + }); + } + if (opts.body) { + const body = el.createDiv({ cls: "pk-card-body" }); + if (typeof opts.body === "string") { + body.setText(opts.body); + } else { + opts.body(body); + } + } + return el; +} + +export function menu(ev: MouseEvent, items: Array<{ title: string; icon?: string; onClick: () => void }>): void { + const m = new Menu(); + for (const it of items) { + m.addItem((i) => { + i.setTitle(it.title); + if (it.icon) i.setIcon(it.icon); + i.onClick(it.onClick); + }); + } + m.showAtMouseEvent(ev); +} + +export async function openMarkdown( + app: App, + path: string, + leaf?: WorkspaceLeaf, +): Promise { + const p = normalizePath(path); + const f = app.vault.getAbstractFileByPath(p); + if (!(f instanceof TFile)) return; + if (Platform.isMobile) { + const target = leaf ?? app.workspace.getLeaf(false); + await target.openFile(f); + return; + } + const existing = app.workspace.getLeavesOfType("markdown"); + if (existing.length > 0) { + const target = existing[existing.length - 1]; + await target.openFile(f); + app.workspace.revealLeaf(target); + } else { + const target = app.workspace.getLeaf("split", "vertical"); + await target.openFile(f); + } +} + +export interface BreadcrumbSegment { + label: string; + onClick?: () => void; +} + +function renderBreadcrumbInto(wrap: HTMLElement, segments: BreadcrumbSegment[]): void { + segments.forEach((seg, i) => { + if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " }); + if (seg.onClick) { + const a = wrap.createSpan({ cls: "pk-breadcrumb-link", text: seg.label }); + a.addEventListener("click", seg.onClick); + } else { + wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label }); + } + }); +} + +export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement { + const wrap = parent.createDiv({ cls: "pk-breadcrumb" }); + renderBreadcrumbInto(wrap, segments); + return wrap; +} + +export function injectMobileBreadcrumb( + view: View & { contentEl?: HTMLElement }, + segments: BreadcrumbSegment[], +): void { + const root = view.contentEl ?? view.containerEl.querySelector(".view-content"); + if (!root) return; + root.querySelectorAll(".pk-mobile-breadcrumb").forEach((el) => el.remove()); + if (segments.length === 0) return; + const target = + root.querySelector(".markdown-source-view .cm-sizer") ?? + root.querySelector(".markdown-reading-view .markdown-preview-sizer"); + if (!target) return; + const wrap = document.createElement("div"); + wrap.className = "pk-breadcrumb pk-mobile-breadcrumb"; + target.prepend(wrap); + renderBreadcrumbInto(wrap, segments); +} + +export function attachEmptyAreaMenu( + el: HTMLElement, + items: () => Array<{ title: string; icon?: string; onClick: () => void }>, +): void { + el.addEventListener("contextmenu", (ev) => { + if (ev.defaultPrevented) return; + ev.preventDefault(); + menu(ev, items()); + }); +} + +export function emptyState(parent: HTMLElement, text: string): HTMLElement { + return parent.createDiv({ cls: "pk-empty", text }); +} diff --git a/src/views/ProjectDetailsView.ts b/src/views/ProjectDetailsView.ts new file mode 100644 index 0000000..27d7b98 --- /dev/null +++ b/src/views/ProjectDetailsView.ts @@ -0,0 +1,424 @@ +import { + ItemView, + MarkdownRenderer, + Notice, + WorkspaceLeaf, + ViewStateResult, + normalizePath, +} from "obsidian"; +import cytoscape, { Core, NodeSingular } from "cytoscape"; +// @ts-ignore — no types for cytoscape-fcose +import fcose from "cytoscape-fcose"; +import { + VIEW_TYPE_PROJECT_DETAILS_VIEW, + VIEW_TYPE_PROJECT_VIEW, + RIBBON_ICON, + CORE_FILE, + DESCRIPTION_FILE, +} from "../const"; +import { + projectPath, + readFile, + deleteRecursive, + createNode, + renameNode, + moveNode, + buildNodeTree, + nodeMdPath, + NodeTree, +} from "../fs"; +import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; +import { NameModal } from "../modals/NameModal"; + +cytoscape.use(fcose); + +export interface ProjectDetailsState extends Record { + project: string; +} + +const NAME_RX = /^[^\\/:*?"<>|]+$/; +const ROOT_ID = ":root:"; + +function validateName(name: string, taken: string[], current?: string): string | null { + if (!name) return "Name darf nicht leer sein."; + if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen."; + if (taken.includes(name) && name !== current) return "Name existiert bereits."; + return null; +} + +export class ProjectDetailsView extends ItemView { + project = ""; + private renderToken = 0; + private cy: Core | null = null; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + this.navigation = true; + } + + getViewType(): string { + return VIEW_TYPE_PROJECT_DETAILS_VIEW; + } + + getDisplayText(): string { + return this.project ? `Projekt: ${this.project}` : "Projekt"; + } + + getIcon(): string { + return RIBBON_ICON; + } + + async setState(state: ProjectDetailsState, result: ViewStateResult): Promise { + this.project = state?.project ?? ""; + await super.setState(state, result); + await this.render(); + } + + getState(): ProjectDetailsState { + return { project: this.project }; + } + + async onOpen(): Promise { + await this.render(); + this.registerEvent(this.app.vault.on("create", () => this.render())); + this.registerEvent(this.app.vault.on("delete", () => this.render())); + this.registerEvent(this.app.vault.on("rename", () => this.render())); + this.registerEvent(this.app.vault.on("modify", (f) => { + if (f.path.startsWith(projectPath(this.project) + "/")) this.render(); + })); + } + + async onClose(): Promise { + this.destroyCy(); + } + + private destroyCy(): void { + if (this.cy) { + this.cy.destroy(); + this.cy = null; + } + } + + private async render(): Promise { + const token = ++this.renderToken; + const root = this.containerEl.children[1] as HTMLElement; + + if (!this.project) { + this.destroyCy(); + root.empty(); + root.addClass("pk-root"); + emptyState(root, "Kein Projekt ausgewählt."); + return; + } + + const projRoot = projectPath(this.project); + const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); + const descriptionPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`); + const [core, description] = await Promise.all([ + readFile(this.app, corePath), + readFile(this.app, descriptionPath), + ]); + if (token !== this.renderToken) return; + + this.destroyCy(); + root.empty(); + root.addClass("pk-root"); + + breadcrumb(root, [ + { label: "Projekte", onClick: () => this.openProjectView() }, + { label: this.project }, + ]); + + const info = root.createDiv({ cls: "pk-info-grid" }); + this.renderInfoCard(info, core, corePath); + this.renderInfoCard(info, description, descriptionPath); + this.renderGraph(root); + } + + private renderInfoCard(parent: HTMLElement, content: string, path: string): void { + const btn = parent.createDiv({ + cls: "pk-btn-card pk-info-card", + attr: { role: "button", tabindex: "0" }, + }); + btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf)); + if (content.trim()) { + void MarkdownRenderer.render(this.app, content, btn, path, this); + } else { + btn.setText("(leer)"); + } + } + + private renderGraph(parent: HTMLElement): void { + const container = parent.createDiv({ cls: "pk-graph" }); + const tree = buildNodeTree(this.app, this.project); + const elements = this.treeToElements(tree); + const styles = getComputedStyle(this.containerEl); + const textColor = styles.getPropertyValue("--text-normal").trim() || "#222"; + const borderColor = styles.getPropertyValue("--background-modifier-border").trim() || "#ccc"; + const accent = styles.getPropertyValue("--interactive-accent").trim() || "#7b6cd9"; + const bgNode = styles.getPropertyValue("--background-primary").trim() || "#fff"; + + this.cy = cytoscape({ + container, + elements, + wheelSensitivity: 0.3, + boxSelectionEnabled: false, + userPanningEnabled: true, + style: [ + { + selector: "node", + style: { + label: "data(label)", + "text-valign": "center", + "text-halign": "center", + "background-color": bgNode, + "border-color": borderColor, + "border-width": 1, + color: textColor, + "font-size": 13, + shape: "round-rectangle", + width: "label" as unknown as number, + height: 28, + padding: "10px", + }, + }, + { + selector: `node[id = "${ROOT_ID}"]`, + style: { + "background-color": accent, + color: "#fff", + "border-width": 0, + "font-weight": "bold", + }, + }, + { + selector: "node.pk-drop-target", + style: { + "border-color": accent, + "border-width": 3, + }, + }, + { + selector: "edge", + style: { + width: 1.5, + "line-color": borderColor, + "target-arrow-color": borderColor, + "target-arrow-shape": "triangle", + "curve-style": "bezier", + }, + }, + ], + layout: { + name: "fcose", + // @ts-ignore — fcose options not in cytoscape types + quality: "default", + randomize: true, + animate: false, + fit: true, + padding: 30, + nodeRepulsion: 8000, + idealEdgeLength: 100, + gravity: 0.25, + fixedNodeConstraint: [ + { nodeId: ROOT_ID, position: { x: 0, y: 0 } }, + ], + }, + }); + + this.bindGraphHandlers(); + } + + private treeToElements(tree: NodeTree): cytoscape.ElementDefinition[] { + const els: cytoscape.ElementDefinition[] = []; + els.push({ data: { id: ROOT_ID, label: tree.name } }); + const walk = (node: NodeTree, parentId: string) => { + for (const child of node.children) { + els.push({ data: { id: child.path, label: child.name } }); + els.push({ data: { id: `${parentId}>${child.path}`, source: parentId, target: child.path } }); + walk(child, child.path); + } + }; + walk(tree, ROOT_ID); + return els; + } + + private bindGraphHandlers(): void { + if (!this.cy) return; + const cy = this.cy; + + cy.on("tap", "node", (ev) => { + const node = ev.target as NodeSingular; + const id = node.id(); + if (id === ROOT_ID) { + const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`); + void openMarkdown(this.app, corePath, this.leaf); + return; + } + void openMarkdown(this.app, nodeMdPath(id), this.leaf); + }); + + cy.on("cxttap", "node", (ev) => { + const node = ev.target as NodeSingular; + const id = node.id(); + const originalEvent = ev.originalEvent as MouseEvent; + originalEvent.preventDefault(); + if (id === ROOT_ID) { + const parent = projectPath(this.project); + menu(originalEvent, [ + { title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(parent) }, + ]); + return; + } + const folder = id; + const parentPath = folder.substring(0, folder.lastIndexOf("/")); + const currentName = folder.split("/").pop() ?? ""; + menu(originalEvent, [ + { title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(folder) }, + { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(folder, currentName, parentPath) }, + { title: "Löschen", icon: "trash", onClick: () => this.openDelete(folder) }, + ]); + }); + + cy.on("cxttap", (ev) => { + if (ev.target !== cy) return; + const originalEvent = ev.originalEvent as MouseEvent; + originalEvent.preventDefault(); + menu(originalEvent, [ + { title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(projectPath(this.project)) }, + ]); + }); + + let grabbed: NodeSingular | null = null; + let grabStart: { x: number; y: number } | null = null; + + cy.on("grab", "node", (ev) => { + const n = ev.target as NodeSingular; + if (n.id() === ROOT_ID) return; + grabbed = n; + const pos = n.position(); + grabStart = { x: pos.x, y: pos.y }; + }); + + cy.on("drag", "node", (ev) => { + if (!grabbed || ev.target.id() !== grabbed.id()) return; + const hovered = this.nodeAtPoint(grabbed.position(), grabbed.id()); + cy.nodes(".pk-drop-target").removeClass("pk-drop-target"); + if (hovered) hovered.addClass("pk-drop-target"); + }); + + cy.on("free", "node", (ev) => { + if (!grabbed || ev.target.id() !== grabbed.id()) return; + const node = grabbed; + const start = grabStart; + grabbed = null; + grabStart = null; + cy.nodes(".pk-drop-target").removeClass("pk-drop-target"); + + const target = this.nodeAtPoint(node.position(), node.id()); + if (!target) { + if (start) node.position(start); + return; + } + const sourcePath = node.id(); + const targetPath = target.id() === ROOT_ID ? projectPath(this.project) : target.id(); + const currentParent = sourcePath.substring(0, sourcePath.lastIndexOf("/")); + if (currentParent === targetPath) { + if (start) node.position(start); + return; + } + void this.handleReparent(sourcePath, targetPath); + }); + } + + private nodeAtPoint(pos: { x: number; y: number }, excludeId: string): NodeSingular | null { + if (!this.cy) return null; + let hit: NodeSingular | null = null; + this.cy.nodes().forEach((n) => { + if (n.id() === excludeId) return; + const bb = n.boundingBox({}); + if (pos.x >= bb.x1 && pos.x <= bb.x2 && pos.y >= bb.y1 && pos.y <= bb.y2) { + hit = n; + } + }); + return hit; + } + + private async handleReparent(sourcePath: string, newParentFolderPath: string): Promise { + const name = sourcePath.split("/").pop() ?? ""; + const target = normalizePath(`${newParentFolderPath}/${name}`); + if (this.app.vault.getAbstractFileByPath(target)) { + new Notice(`„${target}" existiert bereits.`); + await this.render(); + return; + } + try { + await moveNode(this.app, sourcePath, newParentFolderPath); + } catch (e) { + new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`); + await this.render(); + } + } + + private openCreateChild(parentFolderPath: string): void { + const existing = this.childNames(parentFolderPath); + new NameModal(this.app, { + title: "Neuer Node", + label: "Name", + cta: "Erstellen", + validate: (n) => validateName(n, existing), + onSubmit: async (name) => { + try { + await createNode(this.app, parentFolderPath, name); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private openRename(folderPath: string, current: string, parentFolderPath: string): void { + const taken = this.childNames(parentFolderPath); + new NameModal(this.app, { + title: "Node umbenennen", + label: "Name", + initial: current, + cta: "Speichern", + validate: (n) => validateName(n, taken, current), + onSubmit: async (name) => { + if (name === current) return; + try { + await renameNode(this.app, folderPath, name); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private async openDelete(folderPath: string): Promise { + try { + await deleteRecursive(this.app, folderPath); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + } + + private childNames(folderPath: string): string[] { + const folder = this.app.vault.getAbstractFileByPath(normalizePath(folderPath)); + if (!folder || !("children" in folder)) return []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children = (folder as any).children as Array<{ name: string }> | undefined; + if (!children) return []; + return children + .filter((c) => !c.name.endsWith(".md")) + .map((c) => c.name); + } + + private async openProjectView(): Promise { + await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }); + } +} diff --git a/src/views/ProjectView.ts b/src/views/ProjectView.ts new file mode 100644 index 0000000..aca4dfb --- /dev/null +++ b/src/views/ProjectView.ts @@ -0,0 +1,181 @@ +import { + ItemView, + MarkdownRenderer, + Notice, + WorkspaceLeaf, + normalizePath, +} from "obsidian"; +import { + VIEW_TYPE_PROJECT_VIEW, + VIEW_TYPE_PROJECT_DETAILS_VIEW, + RIBBON_ICON, + CORE_FILE, + PROJECTS_ROOT, +} from "../const"; +import { + projectsPath, + projectPath, + listFolders, + ensureFolder, + createProject, + rename, + deleteRecursive, + readFile, +} from "../fs"; +import { menu, breadcrumb, emptyState } from "../ui"; +import { NameModal } from "../modals/NameModal"; + +const NAME_RX = /^[^\\/:*?"<>|]+$/; + +function validateName(name: string, taken: string[], current?: string): string | null { + if (!name) return "Name darf nicht leer sein."; + if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen."; + if (taken.includes(name) && name !== current) return "Name existiert bereits."; + return null; +} + +export class ProjectView extends ItemView { + private renderToken = 0; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + this.navigation = true; + } + + getViewType(): string { + return VIEW_TYPE_PROJECT_VIEW; + } + + getDisplayText(): string { + return "Projekte"; + } + + getIcon(): string { + return RIBBON_ICON; + } + + async onOpen(): Promise { + await ensureFolder(this.app, projectsPath()); + await this.render(); + this.registerEvent(this.app.vault.on("create", () => this.render())); + this.registerEvent(this.app.vault.on("delete", () => this.render())); + this.registerEvent(this.app.vault.on("rename", () => this.render())); + this.registerEvent(this.app.vault.on("modify", (f) => { + if (!f.path.endsWith("/" + CORE_FILE)) return; + if (PROJECTS_ROOT && !f.path.startsWith(projectsPath() + "/")) return; + this.render(); + })); + this.registerDomEvent(this.containerEl, "contextmenu", (ev) => { + if (ev.defaultPrevented) return; + ev.preventDefault(); + menu(ev, [ + { title: "Neues Projekt", icon: "plus", onClick: () => this.openCreate() }, + ]); + }); + } + + async onClose(): Promise {} + + private async render(): Promise { + const token = ++this.renderToken; + const root = this.containerEl.children[1] as HTMLElement; + + const projects = listFolders(this.app, projectsPath()); + if (projects.length === 0) { + if (token !== this.renderToken) return; + root.empty(); + root.addClass("pk-root"); + breadcrumb(root, [{ label: "Projekte" }]); + emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt."); + return; + } + + const taken = projects.map((p) => p.name); + const corePaths = projects.map((p) => normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)); + const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p))); + if (token !== this.renderToken) return; + + root.empty(); + root.addClass("pk-root"); + breadcrumb(root, [{ label: "Projekte" }]); + + const grid = root.createDiv({ cls: "pk-grid" }); + for (let i = 0; i < projects.length; i++) { + const proj = projects[i]; + const core = cores[i].trim(); + const corePath = corePaths[i]; + const btn = grid.createDiv({ + cls: "pk-btn-card", + attr: { role: "button", tabindex: "0" }, + }); + btn.createEl("strong", { text: proj.name }); + if (core) { + const body = btn.createDiv({ cls: "pk-project-core" }); + void MarkdownRenderer.render(this.app, core, body, corePath, this); + } + btn.addEventListener("click", () => this.openProjectDetails(proj.name)); + btn.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + menu(ev, [ + { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) }, + { title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) }, + ]); + }); + } + } + + private openCreate(): void { + const taken = listFolders(this.app, projectsPath()).map((p) => p.name); + new NameModal(this.app, { + title: "Neues Projekt", + label: "Projektname", + cta: "Erstellen", + validate: (n) => validateName(n, taken), + onSubmit: async (name) => { + try { + await createProject(this.app, name); + await this.render(); + new Notice(`Projekt „${name}" erstellt.`); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private openRename(current: string, taken: string[]): void { + new NameModal(this.app, { + title: "Projekt umbenennen", + label: "Projektname", + initial: current, + cta: "Speichern", + validate: (n) => validateName(n, taken, current), + onSubmit: async (name) => { + if (name === current) return; + try { + await rename(this.app, projectPath(current), projectPath(name)); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private async openDelete(name: string): Promise { + try { + await deleteRecursive(this.app, projectPath(name)); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + } + + private async openProjectDetails(name: string): Promise { + await this.leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { project: name }, + }); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..00aade2 --- /dev/null +++ b/styles.css @@ -0,0 +1,188 @@ +.pk-root { + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + container-type: inline-size; +} + +.pk-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.pk-title { + font-size: 1.2em; + font-weight: 600; + flex: 1; +} + +.pk-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; +} + +.pk-info-grid { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 5px; +} + +@container (max-width: 500px) { + .pk-info-grid { + grid-template-columns: 1fr; + } +} + +.pk-card { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.pk-card-header { + font-weight: 600; +} + +.pk-clickable { + cursor: pointer; +} + +.pk-clickable:hover { + color: var(--text-accent); +} + +.pk-card-body { + font-size: 0.9em; + color: var(--text-muted); + white-space: pre-wrap; +} + +.pk-empty { + color: var(--text-muted); + padding: 20px; + text-align: center; +} + +.pk-modal-error { + color: var(--text-error); + margin-top: 8px; + font-size: 0.9em; +} + +.pk-confirm-msg { + margin-bottom: 12px; +} + +.pk-section-title { + font-weight: 600; + margin-top: 8px; +} + +.pk-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + font-size: 1.2em; + font-weight: 600; +} + +.pk-breadcrumb-link { + cursor: pointer; + color: var(--text-muted); + font-weight: 500; +} + +.pk-breadcrumb-link:hover { + color: var(--text-accent); +} + +.pk-breadcrumb-current { + color: var(--text-normal); +} + +.pk-breadcrumb-sep { + color: var(--text-muted); + margin: 0 6px; + font-weight: 400; +} + +.pk-mobile-breadcrumb { + position: relative; + z-index: 5; + background: var(--background-primary); + width: 100%; + flex-shrink: 0; + padding: 8px 12px; + margin-bottom: 8px; + border-bottom: 1px solid var(--background-modifier-border); + box-sizing: border-box; +} + +.pk-btn-card { + box-sizing: border-box; + padding: 10px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + cursor: pointer; + overflow-wrap: anywhere; + display: flex; + flex-direction: column; + gap: 6px; +} + +.pk-btn-card:hover { + background: var(--background-modifier-hover); +} + +.pk-btn-card strong { + font-weight: 600; +} + +.pk-project-core { + font-size: 0.85em; + color: var(--text-muted); +} +.pk-project-core > p:first-child { margin-top: 0; } +.pk-project-core > p:last-child { margin-bottom: 0; } +.pk-project-core > ul:first-child, +.pk-project-core > ol:first-child { margin-top: 0; } +.pk-project-core > ul:last-child, +.pk-project-core > ol:last-child { margin-bottom: 0; } + +.pk-info-card > p:first-child { margin-top: 0; } +.pk-info-card > p:last-child { margin-bottom: 0; } +.pk-info-card > ul:first-child, +.pk-info-card > ol:first-child { margin-top: 0; } +.pk-info-card > ul:last-child, +.pk-info-card > ol:last-child { margin-bottom: 0; } + +.pk-project-core p, +.pk-project-core ul, +.pk-project-core ol, +.pk-info-card p, +.pk-info-card ul, +.pk-info-card ol { + margin: 0.25em 0; +} + +.pk-graph { + width: 100%; + min-height: 60vh; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; +} + +@container (max-width: 500px) { + .pk-graph { + min-height: 50vh; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0410e27 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2020", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["DOM", "ES2020"] + }, + "include": ["main.ts", "src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +}