From a1b1b2d11c1f9b853825f92b47e3dfa33d3ccf98 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 23 May 2026 00:42:25 +0200 Subject: [PATCH] init --- .gitignore | 4 + esbuild.config.mjs | 40 ++ main.ts | 97 +++++ manifest.json | 9 + package-lock.json | 630 ++++++++++++++++++++++++++++++++ package.json | 25 ++ src/const.ts | 15 + src/fs.ts | 177 +++++++++ src/graph.ts | 137 +++++++ src/modals/NameModal.ts | 68 ++++ src/ui.ts | 85 +++++ src/views/GraphView.ts | 352 ++++++++++++++++++ src/views/GridView.ts | 285 +++++++++++++++ src/views/ProjectDetailsView.ts | 184 ++++++++++ src/views/ProjectView.ts | 182 +++++++++ styles.css | 251 +++++++++++++ tsconfig.json | 19 + versions.json | 3 + 18 files changed, 2563 insertions(+) create mode 100644 .gitignore create mode 100644 esbuild.config.mjs create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/const.ts create mode 100644 src/fs.ts create mode 100644 src/graph.ts create mode 100644 src/modals/NameModal.ts create mode 100644 src/ui.ts create mode 100644 src/views/GraphView.ts create mode 100644 src/views/GridView.ts create mode 100644 src/views/ProjectDetailsView.ts create mode 100644 src/views/ProjectView.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 versions.json 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..8bcc29b --- /dev/null +++ b/main.ts @@ -0,0 +1,97 @@ +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 } from "./src/fs"; +import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui"; + +export default class ContexterPlugin 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.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 }, + }), + }, + ]; + if (loc.collection) { + segments.push({ + label: loc.collection, + onClick: () => + void leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { project: loc.project, tab: "grid" }, + }), + }); + } + segments.push({ label: file.basename }); + injectMobileBreadcrumb(view, segments); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6a7c56a --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "contexter", + "name": "Contexter", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Projekte zweigleisig strukturieren: flacher Collection-Grid und unabhängiger Mindmap-Graph.", + "author": "marha", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fa95ef8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,630 @@ +{ + "name": "contexter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "contexter", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@types/cytoscape": "^3.21.9", + "cytoscape": "^3.33.4" + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "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/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/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/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..719dd9d --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "contexter", + "version": "0.1.0", + "description": "Obsidian Plugin - Grid + unabhängiger Graph für Projekte", + "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" + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..32d55f7 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,15 @@ +export const PROJECTS_ROOT = ""; + +export const VIEW_TYPE_PROJECT_VIEW = "contexter-projects"; +export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "contexter-overview"; + +export const RIBBON_ICON = "layout-grid"; + +export const CORE_FILE = "_core.md"; +export const DESCRIPTION_FILE = "_description.md"; +export const GRAPH_FILE = "_graph.json"; + +export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE, GRAPH_FILE] as const; + +export type Tab = "grid" | "graph"; +export const DEFAULT_TAB: Tab = "grid"; diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..dfcf8df --- /dev/null +++ b/src/fs.ts @@ -0,0 +1,177 @@ +import { App, TFile, TFolder, normalizePath } from "obsidian"; +import { PROJECTS_ROOT, CORE_FILE, DESCRIPTION_FILE, GRAPH_FILE } from "./const"; + +export function projectsPath(): string { + return PROJECTS_ROOT; +} + +export function projectPath(name: string): string { + return normalizePath(`${PROJECTS_ROOT}/${name}`); +} + +export function collectionPath(project: string, collection: string): string { + return normalizePath(`${projectPath(project)}/${collection}`); +} + +export function collectionMdPath(project: string, collection: string): string { + return normalizePath(`${collectionPath(project, collection)}/${collection}.md`); +} + +export function featurePath(project: string, collection: string, feature: string): string { + const file = feature.endsWith(".md") ? feature : `${feature}.md`; + return normalizePath(`${collectionPath(project, collection)}/${file}`); +} + +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 function listCollectionFeatures( + app: App, + project: string, + collection: string, +): TFile[] { + const ownMd = `${collection}.md`; + return listMarkdownFiles(app, collectionPath(project, collection), [ownMd]); +} + +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 writeFile(app: App, path: string, content: string): Promise { + const p = normalizePath(path); + const existing = app.vault.getAbstractFileByPath(p); + if (existing instanceof TFile) { + await app.vault.modify(existing, content); + } else { + await app.vault.create(p, content); + } +} + +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); + await ensureFile(app, normalizePath(`${root}/${CORE_FILE}`), ""); + await ensureFile(app, normalizePath(`${root}/${DESCRIPTION_FILE}`), ""); + await ensureFile(app, normalizePath(`${root}/${GRAPH_FILE}`), '{"edges":[]}'); +} + +export async function createCollection( + app: App, + project: string, + name: string, +): Promise { + await ensureFolder(app, collectionPath(project, name)); + await ensureFile(app, collectionMdPath(project, name), ""); +} + +export async function renameCollection( + app: App, + project: string, + oldName: string, + newName: string, +): Promise { + if (oldName === newName) return; + const oldFolder = collectionPath(project, oldName); + const newFolder = collectionPath(project, newName); + const oldMd = collectionMdPath(project, oldName); + const tmpMd = normalizePath(`${oldFolder}/${newName}.md`); + const mdFile = app.vault.getAbstractFileByPath(oldMd); + if (mdFile instanceof TFile && tmpMd !== oldMd) { + await rename(app, oldMd, tmpMd); + } + await rename(app, oldFolder, newFolder); +} + +export async function createFeature( + app: App, + project: string, + collection: string, + feature: string, +): Promise { + return await ensureFile(app, featurePath(project, collection, feature), ""); +} + +export async function moveFeature( + app: App, + sourcePath: string, + project: string, + targetCollection: string, +): Promise { + const file = sourcePath.split("/").pop() ?? ""; + const target = normalizePath(`${collectionPath(project, targetCollection)}/${file}`); + if (sourcePath === target) return target; + await rename(app, sourcePath, target); + return target; +} + +export interface ProjectFileLocation { + project: string; + collection?: string; + file: 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); + if (rest.length === 1) return { project, file: rest[0] }; + if (rest.length === 2) return { project, collection: rest[0], file: rest[1] }; + return null; +} diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..e28a995 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,137 @@ +import { App, normalizePath } from "obsidian"; +import { GRAPH_FILE } from "./const"; +import { projectPath, readFile, writeFile, listFolders } from "./fs"; + +export interface GraphEdge { + parent: string | null; + child: string; +} + +export interface GraphData { + edges: GraphEdge[]; +} + +export function graphPath(project: string): string { + return normalizePath(`${projectPath(project)}/${GRAPH_FILE}`); +} + +export async function readGraph(app: App, project: string): Promise { + const raw = await readFile(app, graphPath(project)); + if (!raw.trim()) return { edges: [] }; + try { + const data = JSON.parse(raw); + if (!Array.isArray(data?.edges)) return { edges: [] }; + const edges: GraphEdge[] = data.edges + .filter((e: unknown): e is GraphEdge => { + const c = e as Partial; + return typeof c?.child === "string" && + (c.parent === null || typeof c.parent === "string"); + }); + return { edges }; + } catch { + return { edges: [] }; + } +} + +export async function writeGraph(app: App, project: string, data: GraphData): Promise { + await writeFile(app, graphPath(project), JSON.stringify(data, null, 2)); +} + +export function childrenOf(data: GraphData, parent: string | null): string[] { + return data.edges.filter((e) => e.parent === parent).map((e) => e.child); +} + +export function parentOf(data: GraphData, child: string): string | null | undefined { + const e = data.edges.find((edge) => edge.child === child); + return e ? e.parent : undefined; +} + +export function descendants(data: GraphData, node: string): string[] { + const out: string[] = []; + const walk = (parent: string) => { + for (const c of childrenOf(data, parent)) { + out.push(c); + walk(c); + } + }; + walk(node); + return out; +} + +export function setParent( + data: GraphData, + child: string, + newParent: string | null, +): GraphData { + if (child === newParent) return data; + // Cycle-Schutz: newParent darf kein Nachfahre von child sein + if (newParent !== null && descendants(data, child).includes(newParent)) { + return data; + } + const filtered = data.edges.filter((e) => e.child !== child); + filtered.push({ parent: newParent, child }); + return { edges: filtered }; +} + +export function addChild( + data: GraphData, + parent: string | null, + child: string, +): GraphData { + if (data.edges.some((e) => e.child === child)) return data; + return { edges: [...data.edges, { parent, child }] }; +} + +export function renameNode( + data: GraphData, + oldName: string, + newName: string, +): GraphData { + if (oldName === newName) return data; + const edges = data.edges.map((e) => ({ + parent: e.parent === oldName ? newName : e.parent, + child: e.child === oldName ? newName : e.child, + })); + return { edges }; +} + +export function removeNodeAndDescendants( + data: GraphData, + node: string, +): { data: GraphData; removed: string[] } { + const removed = [node, ...descendants(data, node)]; + const set = new Set(removed); + const edges = data.edges.filter((e) => !set.has(e.child) && !(e.parent !== null && set.has(e.parent))); + return { data: { edges }, removed }; +} + +export function removeNodeKeepChildren(data: GraphData, node: string): GraphData { + const edges = data.edges + .filter((e) => e.child !== node) + .map((e) => (e.parent === node ? { parent: null, child: e.child } : e)); + return { edges }; +} + +export function reconcileWithFolders( + data: GraphData, + collectionNames: string[], +): GraphData { + const known = new Set(collectionNames); + let edges = data.edges.filter((e) => known.has(e.child)); + edges = edges.map((e) => (e.parent !== null && !known.has(e.parent) ? { parent: null, child: e.child } : e)); + const present = new Set(edges.map((e) => e.child)); + for (const name of collectionNames) { + if (!present.has(name)) edges.push({ parent: null, child: name }); + } + return { edges }; +} + +export async function syncGraphWithFolders(app: App, project: string): Promise { + const folders = listFolders(app, projectPath(project)).map((f) => f.name); + const data = await readGraph(app, project); + const reconciled = reconcileWithFolders(data, folders); + if (JSON.stringify(reconciled) !== JSON.stringify(data)) { + await writeGraph(app, project, reconciled); + } + return reconciled; +} 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..38d4c77 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,85 @@ +import { App, Menu, Platform, TFile, View, WorkspaceLeaf, normalizePath } from "obsidian"; + +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 emptyState(parent: HTMLElement, text: string): HTMLElement { + return parent.createDiv({ cls: "pk-empty", text }); +} diff --git a/src/views/GraphView.ts b/src/views/GraphView.ts new file mode 100644 index 0000000..da4fa11 --- /dev/null +++ b/src/views/GraphView.ts @@ -0,0 +1,352 @@ +import { App, Notice, WorkspaceLeaf, normalizePath } from "obsidian"; +import cytoscape, { Core, NodeSingular } from "cytoscape"; +import { CORE_FILE } from "../const"; +import { + projectPath, + collectionPath, + collectionMdPath, + listFolders, + listCollectionFeatures, + createCollection, + renameCollection, + deleteRecursive, +} from "../fs"; +import { menu, openMarkdown } from "../ui"; +import { NameModal } from "../modals/NameModal"; +import { + readGraph, + writeGraph, + addChild, + setParent, + renameNode as renameGraphNode, + removeNodeAndDescendants, + syncGraphWithFolders, + GraphData, +} from "../graph"; + +const NAME_RX = /^[^\\/:*?"<>|]+$/; +const ROOT_ID = "__pk_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 GraphView { + private cy: Core | null = null; + private host: HTMLElement | null = null; + + constructor( + private app: App, + private project: string, + private leaf: WorkspaceLeaf, + private onChange: () => void, + ) {} + + destroy(): void { + if (this.cy) { + this.cy.destroy(); + this.cy = null; + } + this.host = null; + } + + async render(parent: HTMLElement): Promise { + this.destroy(); + const container = parent.createDiv({ cls: "pk-graph" }); + this.host = container; + + const data = await syncGraphWithFolders(this.app, this.project); + const collections = listFolders(this.app, projectPath(this.project)).map((f) => f.name); + + const elements = this.buildElements(data, collections); + const styles = getComputedStyle(parent); + const textColor = styles.getPropertyValue("--text-normal").trim() || "#222"; + const borderColor = styles.getPropertyValue("--background-modifier-border").trim() || "#ccc"; + const edgeColor = styles.getPropertyValue("--text-muted").trim() || "#888"; + 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", + "text-wrap": "wrap", + "text-max-width": "160px", + "background-color": bgNode, + "border-color": borderColor, + "border-width": 1, + color: textColor, + "font-size": 12, + shape: "round-rectangle", + width: "label" as unknown as number, + height: "label" as unknown as number, + padding: "10px", + }, + }, + { + selector: `node[id = "${ROOT_ID}"]`, + style: { + shape: "ellipse", + "background-color": accent, + color: "#fff", + "border-width": 0, + "font-weight": "bold", + width: 80, + height: 80, + }, + }, + { + selector: "node.pk-drop-target", + style: { + "border-color": accent, + "border-width": 3, + }, + }, + { + selector: "edge", + style: { + width: 2, + "line-color": edgeColor, + "target-arrow-color": edgeColor, + "target-arrow-shape": "triangle", + "curve-style": "bezier", + }, + }, + ], + layout: { + name: "concentric", + concentric: (node) => -((node.data("depth") as number) ?? 0), + levelWidth: () => 1, + minNodeSpacing: 60, + // @ts-ignore — spacingFactor exists in concentric layout + spacingFactor: 1.4, + fit: true, + padding: 30, + startAngle: -Math.PI / 2, + clockwise: true, + }, + }); + + this.bindHandlers(); + } + + private buildElements(data: GraphData, collections: string[]): cytoscape.ElementDefinition[] { + const els: cytoscape.ElementDefinition[] = []; + els.push({ data: { id: ROOT_ID, label: this.project, depth: 0 } }); + + const childrenMap = new Map(); + for (const e of data.edges) { + const arr = childrenMap.get(e.parent) ?? []; + arr.push(e.child); + childrenMap.set(e.parent, arr); + } + + const known = new Set(collections); + const visited = new Set(); + const walk = (parentName: string | null, parentId: string, depth: number) => { + const children = (childrenMap.get(parentName) ?? []).filter((c) => known.has(c)); + for (const child of children) { + if (visited.has(child)) continue; + visited.add(child); + const id = `n_${child}`; + els.push({ + data: { + id, + name: child, + label: this.nodeLabel(child), + depth, + }, + }); + els.push({ data: { source: parentId, target: id } }); + walk(child, id, depth + 1); + } + }; + walk(null, ROOT_ID, 1); + return els; + } + + private nodeLabel(collection: string): string { + const features = listCollectionFeatures(this.app, this.project, collection) + .map((f) => f.basename); + if (features.length === 0) return collection; + return `${collection}\n${features.map((f) => `• ${f}`).join(" ")}`; + } + + private bindHandlers(): void { + if (!this.cy) return; + const cy = this.cy; + + cy.on("tap", "node", (ev) => { + const node = ev.target as NodeSingular; + if (node.id() === ROOT_ID) { + const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`); + void openMarkdown(this.app, corePath, this.leaf); + return; + } + const name = node.data("name") as string; + void openMarkdown(this.app, collectionMdPath(this.project, name), this.leaf); + }); + + cy.on("cxttap", "node", (ev) => { + const node = ev.target as NodeSingular; + const oe = ev.originalEvent as MouseEvent; + oe.preventDefault(); + if (node.id() === ROOT_ID) { + menu(oe, [ + { title: "Node erstellen", icon: "plus", onClick: () => this.openCreateChild(null) }, + ]); + return; + } + const name = node.data("name") as string; + menu(oe, [ + { title: "Child-Node erstellen", icon: "plus", onClick: () => this.openCreateChild(name) }, + { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(name) }, + { title: "Löschen", icon: "trash", onClick: () => this.deleteNode(name) }, + ]); + }); + + cy.on("cxttap", (ev) => { + if (ev.target !== cy) return; + const oe = ev.originalEvent as MouseEvent; + oe.preventDefault(); + menu(oe, [ + { title: "Node erstellen", icon: "plus", onClick: () => this.openCreateChild(null) }, + ]); + }); + + 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 childName = node.data("name") as string; + const newParent = target.id() === ROOT_ID ? null : (target.data("name") as string); + void this.handleReparent(childName, newParent, start); + }); + } + + 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( + child: string, + newParent: string | null, + start: { x: number; y: number } | null, + ): Promise { + const data = await readGraph(this.app, this.project); + const next = setParent(data, child, newParent); + if (JSON.stringify(next) === JSON.stringify(data)) { + if (start && this.cy) { + const n = this.cy.getElementById(`n_${child}`); + if (n.nonempty()) n.position(start); + } + return; + } + await writeGraph(this.app, this.project, next); + this.onChange(); + } + + private openCreateChild(parent: string | null): void { + const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name); + new NameModal(this.app, { + title: "Neuer Node", + label: "Name", + cta: "Erstellen", + validate: (n) => validateName(n, taken), + onSubmit: async (name) => { + try { + await createCollection(this.app, this.project, name); + const data = await readGraph(this.app, this.project); + await writeGraph(this.app, this.project, addChild(data, parent, name)); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private openRename(current: string): void { + const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name); + 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 renameCollection(this.app, this.project, current, name); + const data = await readGraph(this.app, this.project); + await writeGraph(this.app, this.project, renameGraphNode(data, current, name)); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private async deleteNode(name: string): Promise { + try { + const data = await readGraph(this.app, this.project); + const { data: next, removed } = removeNodeAndDescendants(data, name); + for (const coll of removed) { + await deleteRecursive(this.app, collectionPath(this.project, coll)); + } + await writeGraph(this.app, this.project, next); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + } +} diff --git a/src/views/GridView.ts b/src/views/GridView.ts new file mode 100644 index 0000000..feadb53 --- /dev/null +++ b/src/views/GridView.ts @@ -0,0 +1,285 @@ +import { App, Notice, TFile, WorkspaceLeaf, normalizePath } from "obsidian"; +import { + projectPath, + collectionPath, + collectionMdPath, + featurePath, + listFolders, + listCollectionFeatures, + createCollection, + renameCollection, + createFeature, + deleteRecursive, + rename, + moveFeature, +} from "../fs"; +import { menu, openMarkdown, emptyState } from "../ui"; +import { NameModal } from "../modals/NameModal"; +import { + readGraph, + writeGraph, + addChild, + renameNode as renameGraphNode, + removeNodeAndDescendants, +} from "../graph"; + +const NAME_RX = /^[^\\/:*?"<>|]+$/; +const PK_DND_MIME = "application/x-pk-grid"; + +interface DndPayload { + kind: "feature"; + sourcePath: string; + file: string; +} + +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 GridView { + constructor( + private app: App, + private project: string, + private leaf: WorkspaceLeaf, + private onChange: () => void, + ) {} + + render(parent: HTMLElement): void { + const root = parent.createDiv({ cls: "pk-grid-view" }); + root.addEventListener("contextmenu", (ev) => { + if (ev.defaultPrevented) return; + ev.preventDefault(); + menu(ev, [ + { title: "Collection erstellen", icon: "plus", onClick: () => this.openCreateCollection() }, + ]); + }); + + const collections = listFolders(this.app, projectPath(this.project)); + if (collections.length === 0) { + emptyState(root, "Keine Collections. Rechtsklick → Collection erstellen."); + return; + } + + const grid = root.createDiv({ cls: "pk-grid" }); + const takenNames = collections.map((c) => c.name); + for (const folder of collections) { + this.renderCollectionCard(grid, folder.name, takenNames); + } + } + + private renderCollectionCard( + parent: HTMLElement, + collectionName: string, + takenNames: string[], + ): void { + const card = parent.createDiv({ + cls: "pk-btn-card", + attr: { role: "button", tabindex: "0" }, + }); + card.createEl("strong", { text: collectionName }); + + const features = listCollectionFeatures(this.app, this.project, collectionName); + const badges = card.createDiv({ cls: "pk-badges" }); + if (features.length === 0) { + badges.createDiv({ cls: "pk-empty", text: "Keine Features" }); + } else { + for (const f of features) { + this.renderFeatureBadge(badges, collectionName, f); + } + } + + card.addEventListener("click", (ev) => { + if ((ev.target as HTMLElement).closest(".pk-badge")) return; + void openMarkdown(this.app, collectionMdPath(this.project, collectionName), this.leaf); + }); + card.addEventListener("contextmenu", (ev) => { + if ((ev.target as HTMLElement).closest(".pk-badge")) return; + ev.preventDefault(); + ev.stopPropagation(); + menu(ev, [ + { title: "Feature erstellen", icon: "plus", onClick: () => this.openCreateFeature(collectionName) }, + { title: "Collection erstellen", icon: "plus", onClick: () => this.openCreateCollection() }, + { title: "Umbenennen", icon: "pencil", onClick: () => this.openRenameCollection(collectionName, takenNames) }, + { title: "Löschen", icon: "trash", onClick: () => this.deleteCollection(collectionName) }, + ]); + }); + + card.addEventListener("dragover", (ev) => { + if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + card.addClass("pk-drop-target"); + }); + card.addEventListener("dragleave", (ev) => { + const next = ev.relatedTarget as Node | null; + if (next && card.contains(next)) return; + card.removeClass("pk-drop-target"); + }); + card.addEventListener("drop", async (ev) => { + card.removeClass("pk-drop-target"); + const raw = ev.dataTransfer?.getData(PK_DND_MIME); + if (!raw) return; + ev.preventDefault(); + ev.stopPropagation(); + await this.handleDropOnCollection(raw, collectionName); + }); + } + + private renderFeatureBadge(parent: HTMLElement, collectionName: string, file: TFile): void { + const badge = parent.createDiv({ + cls: "pk-badge", + text: file.basename, + attr: { draggable: "true" }, + }); + badge.addEventListener("click", (ev) => { + ev.stopPropagation(); + void openMarkdown(this.app, file.path, this.leaf); + }); + badge.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + menu(ev, [ + { title: "Umbenennen", icon: "pencil", onClick: () => this.openRenameFeature(collectionName, file) }, + { title: "Löschen", icon: "trash", onClick: () => this.deleteFeature(file.path) }, + ]); + }); + badge.addEventListener("dragstart", (ev) => { + if (!ev.dataTransfer) return; + ev.stopPropagation(); + ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({ + kind: "feature", + sourcePath: file.path, + file: file.name, + } satisfies DndPayload)); + ev.dataTransfer.effectAllowed = "move"; + badge.addClass("pk-badge-dragging"); + }); + badge.addEventListener("dragend", () => badge.removeClass("pk-badge-dragging")); + } + + private async handleDropOnCollection(raw: string, targetCollection: string): Promise { + let data: DndPayload | null = null; + try { data = JSON.parse(raw); } catch { return; } + if (!data || data.kind !== "feature") return; + + const newPath = normalizePath( + `${collectionPath(this.project, targetCollection)}/${data.file}`, + ); + if (data.sourcePath === newPath) return; + if (this.app.vault.getAbstractFileByPath(newPath)) { + new Notice(`„${data.file}" existiert in „${targetCollection}" bereits.`); + return; + } + try { + await moveFeature(this.app, data.sourcePath, this.project, targetCollection); + } catch (e) { + new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`); + return; + } + this.onChange(); + } + + private openCreateCollection(): void { + const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name); + new NameModal(this.app, { + title: "Neue Collection", + label: "Collection-Name", + cta: "Erstellen", + validate: (n) => validateName(n, taken), + onSubmit: async (name) => { + try { + await createCollection(this.app, this.project, name); + const data = await readGraph(this.app, this.project); + await writeGraph(this.app, this.project, addChild(data, null, name)); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private openRenameCollection(current: string, takenNames: string[]): void { + new NameModal(this.app, { + title: "Collection umbenennen", + label: "Collection-Name", + initial: current, + cta: "Speichern", + validate: (n) => validateName(n, takenNames, current), + onSubmit: async (name) => { + if (name === current) return; + try { + await renameCollection(this.app, this.project, current, name); + const data = await readGraph(this.app, this.project); + await writeGraph(this.app, this.project, renameGraphNode(data, current, name)); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private async deleteCollection(name: string): Promise { + try { + await deleteRecursive(this.app, collectionPath(this.project, name)); + const data = await readGraph(this.app, this.project); + const { data: next } = removeNodeAndDescendants(data, name); + await writeGraph(this.app, this.project, next); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + } + + private openCreateFeature(collectionName: string): void { + const taken = listCollectionFeatures(this.app, this.project, collectionName).map((f) => f.basename); + new NameModal(this.app, { + title: "Neues Feature", + label: "Feature-Name", + cta: "Erstellen", + validate: (n) => validateName(n, taken), + onSubmit: async (name) => { + try { + await createFeature(this.app, this.project, collectionName, name); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private openRenameFeature(collectionName: string, file: TFile): void { + const taken = listCollectionFeatures(this.app, this.project, collectionName).map((f) => f.basename); + new NameModal(this.app, { + title: "Feature umbenennen", + label: "Feature-Name", + initial: file.basename, + cta: "Speichern", + validate: (n) => validateName(n, taken, file.basename), + onSubmit: async (name) => { + if (name === file.basename) return; + try { + const target = featurePath(this.project, collectionName, name); + await rename(this.app, file.path, target); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + }, + }).open(); + } + + private async deleteFeature(path: string): Promise { + try { + await deleteRecursive(this.app, path); + this.onChange(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } + } +} diff --git a/src/views/ProjectDetailsView.ts b/src/views/ProjectDetailsView.ts new file mode 100644 index 0000000..993a073 --- /dev/null +++ b/src/views/ProjectDetailsView.ts @@ -0,0 +1,184 @@ +import { + ItemView, + MarkdownRenderer, + WorkspaceLeaf, + ViewStateResult, + normalizePath, +} from "obsidian"; +import { + VIEW_TYPE_PROJECT_DETAILS_VIEW, + VIEW_TYPE_PROJECT_VIEW, + RIBBON_ICON, + CORE_FILE, + DESCRIPTION_FILE, + GRAPH_FILE, + Tab, + DEFAULT_TAB, +} from "../const"; +import { projectPath, readFile } from "../fs"; +import { breadcrumb, emptyState, openMarkdown } from "../ui"; +import { GridView } from "./GridView"; +import { GraphView } from "./GraphView"; + +export interface ProjectDetailsState extends Record { + project: string; + tab?: Tab; +} + +export class ProjectDetailsView extends ItemView { + project = ""; + tab: Tab = DEFAULT_TAB; + private renderToken = 0; + private grid: GridView | null = null; + private graph: GraphView | null = null; + private bodyEl: HTMLElement | 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 ?? ""; + this.tab = state?.tab ?? DEFAULT_TAB; + await super.setState(state, result); + await this.render(); + } + + getState(): ProjectDetailsState { + return { project: this.project, tab: this.tab }; + } + + async onOpen(): Promise { + await this.render(); + this.registerEvent(this.app.vault.on("create", (f) => this.onVaultChange(f.path))); + this.registerEvent(this.app.vault.on("delete", (f) => this.onVaultChange(f.path))); + this.registerEvent(this.app.vault.on("rename", (f, old) => { + this.onVaultChange(f.path); + this.onVaultChange(old); + })); + this.registerEvent(this.app.vault.on("modify", (f) => this.onVaultChange(f.path))); + } + + async onClose(): Promise { + this.graph?.destroy(); + } + + private onVaultChange(path: string): void { + if (!this.project) return; + if (!path.startsWith(projectPath(this.project) + "/") && path !== projectPath(this.project)) return; + if (path.endsWith("/" + GRAPH_FILE)) return; // eigene Schreibvorgänge nicht triggern + void this.renderBody(); + } + + private async render(): Promise { + const token = ++this.renderToken; + const root = this.containerEl.children[1] as HTMLElement; + + if (!this.project) { + this.graph?.destroy(); + 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.graph?.destroy(); + 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.renderTabs(root); + this.bodyEl = root.createDiv({ cls: "pk-tab-body" }); + await this.renderBody(); + } + + 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 renderTabs(parent: HTMLElement): void { + const tabs = parent.createDiv({ cls: "pk-tabs" }); + const mkTab = (id: Tab, label: string) => { + const el = tabs.createDiv({ + cls: `pk-tab${this.tab === id ? " pk-tab-active" : ""}`, + text: label, + }); + el.addEventListener("click", () => { + if (this.tab === id) return; + this.tab = id; + void this.persistState(); + void this.render(); + }); + }; + mkTab("grid", "Grid"); + mkTab("graph", "Graph"); + } + + private async persistState(): Promise { + await this.leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { project: this.project, tab: this.tab }, + }); + } + + private async renderBody(): Promise { + if (!this.bodyEl) return; + this.graph?.destroy(); + this.grid = null; + this.graph = null; + this.bodyEl.empty(); + + const refresh = () => void this.renderBody(); + if (this.tab === "grid") { + this.grid = new GridView(this.app, this.project, this.leaf, refresh); + this.grid.render(this.bodyEl); + } else { + this.graph = new GraphView(this.app, this.project, this.leaf, refresh); + await this.graph.render(this.bodyEl); + } + } + + 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..3f179d4 --- /dev/null +++ b/src/views/ProjectView.ts @@ -0,0 +1,182 @@ +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(); + ev.stopPropagation(); + 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..92cd366 --- /dev/null +++ b/styles.css @@ -0,0 +1,251 @@ +.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-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, +.pk-info-card { + font-size: 0.85em; + color: var(--text-muted); +} +.pk-project-core > p:first-child, +.pk-info-card > p:first-child { margin-top: 0; } +.pk-project-core > p:last-child, +.pk-info-card > p:last-child { margin-bottom: 0; } +.pk-project-core > ul:first-child, +.pk-project-core > ol:first-child, +.pk-info-card > ul:first-child, +.pk-info-card > ol:first-child { margin-top: 0; } +.pk-project-core > ul:last-child, +.pk-project-core > ol:last-child, +.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; + } +} + +/* Tabs */ +.pk-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--background-modifier-border); + margin-top: 4px; +} + +.pk-tab { + padding: 6px 14px; + cursor: pointer; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + color: var(--text-muted); + font-weight: 500; + user-select: none; +} + +.pk-tab:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.pk-tab.pk-tab-active { + background: var(--background-secondary); + border-color: var(--background-modifier-border); + color: var(--text-normal); +} + +/* Collection-Card Badges */ +.pk-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.pk-badge { + font-size: 0.8em; + padding: 2px 8px; + border-radius: 12px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); + cursor: pointer; + user-select: none; +} + +.pk-badge:hover { + background: var(--background-modifier-hover); + color: var(--text-accent); +} + +.pk-badge-dragging { + opacity: 0.4; +} + +/* DnD */ +.pk-drop-target { + outline: 2px solid var(--interactive-accent); + outline-offset: -2px; +} + +.pk-card-dragging { + opacity: 0.4; +} 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" +}