init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
main.js
|
||||
*.log
|
||||
.DS_Store
|
||||
40
esbuild.config.mjs
Normal file
40
esbuild.config.mjs
Normal 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();
|
||||
}
|
||||
97
main.ts
Normal file
97
main.ts
Normal file
@@ -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<void> {
|
||||
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<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 },
|
||||
}),
|
||||
},
|
||||
];
|
||||
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);
|
||||
}
|
||||
}
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
@@ -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
|
||||
}
|
||||
630
package-lock.json
generated
Normal file
630
package-lock.json
generated
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
15
src/const.ts
Normal file
15
src/const.ts
Normal file
@@ -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";
|
||||
177
src/fs.ts
Normal file
177
src/fs.ts
Normal file
@@ -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<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 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<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 writeFile(app: App, path: string, content: string): Promise<void> {
|
||||
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<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);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TFile> {
|
||||
return await ensureFile(app, featurePath(project, collection, feature), "");
|
||||
}
|
||||
|
||||
export async function moveFeature(
|
||||
app: App,
|
||||
sourcePath: string,
|
||||
project: string,
|
||||
targetCollection: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
137
src/graph.ts
Normal file
137
src/graph.ts
Normal file
@@ -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<GraphData> {
|
||||
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<GraphEdge>;
|
||||
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<void> {
|
||||
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<GraphData> {
|
||||
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;
|
||||
}
|
||||
68
src/modals/NameModal.ts
Normal file
68
src/modals/NameModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
85
src/ui.ts
Normal file
85
src/ui.ts
Normal file
@@ -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<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 emptyState(parent: HTMLElement, text: string): HTMLElement {
|
||||
return parent.createDiv({ cls: "pk-empty", text });
|
||||
}
|
||||
352
src/views/GraphView.ts
Normal file
352
src/views/GraphView.ts
Normal file
@@ -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<void> {
|
||||
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<string | null, string[]>();
|
||||
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<string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
285
src/views/GridView.ts
Normal file
285
src/views/GridView.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await deleteRecursive(this.app, path);
|
||||
this.onChange();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/views/ProjectDetailsView.ts
Normal file
184
src/views/ProjectDetailsView.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.leaf.setViewState({
|
||||
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||
active: true,
|
||||
state: { project: this.project, tab: this.tab },
|
||||
});
|
||||
}
|
||||
|
||||
private async renderBody(): Promise<void> {
|
||||
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<void> {
|
||||
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||
}
|
||||
}
|
||||
182
src/views/ProjectView.ts
Normal file
182
src/views/ProjectView.ts
Normal file
@@ -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<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();
|
||||
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<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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
251
styles.css
Normal file
251
styles.css
Normal file
@@ -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;
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal 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
3
versions.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"0.1.0": "1.4.0"
|
||||
}
|
||||
Reference in New Issue
Block a user