This commit is contained in:
Team3
2026-05-22 00:01:59 +02:00
commit b7759d6542
15 changed files with 2032 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
main.js
*.log
.DS_Store

40
esbuild.config.mjs Normal file
View File

@@ -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();
}

116
main.ts Normal file
View File

@@ -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<void> {
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<void> {}
async activateProjectView(): Promise<void> {
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);
}
}

9
manifest.json Normal file
View File

@@ -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
}

658
package-lock.json generated Normal file
View File

@@ -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
}
}
}

26
package.json Normal file
View File

@@ -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"
}
}

11
src/const.ts Normal file
View File

@@ -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;

160
src/fs.ts Normal file
View File

@@ -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<void> {
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<TFile> {
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<string> {
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<void> {
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<void> {
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<void> {
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<TFile> {
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<void> {
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<void> {
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 };
}

68
src/modals/NameModal.ts Normal file
View File

@@ -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<void>;
}
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<void> {
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();
}
}

125
src/ui.ts Normal file
View File

@@ -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<void> {
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<HTMLElement>(".markdown-source-view .cm-sizer") ??
root.querySelector<HTMLElement>(".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 });
}

View File

@@ -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<string, unknown> {
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<void> {
this.project = state?.project ?? "";
await super.setState(state, result);
await this.render();
}
getState(): ProjectDetailsState {
return { project: this.project };
}
async onOpen(): Promise<void> {
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<void> {
this.destroyCy();
}
private destroyCy(): void {
if (this.cy) {
this.cy.destroy();
this.cy = null;
}
}
private async render(): Promise<void> {
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<void> {
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<void> {
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<void> {
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
}
}

181
src/views/ProjectView.ts Normal file
View File

@@ -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<void> {
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<void> {}
private async render(): Promise<void> {
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<void> {
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<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { project: name },
});
}
}

188
styles.css Normal file
View File

@@ -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;
}
}

19
tsconfig.json Normal file
View File

@@ -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"]
}

3
versions.json Normal file
View File

@@ -0,0 +1,3 @@
{
"0.1.0": "1.4.0"
}