This commit is contained in:
2026-04-30 20:10:14 +02:00
commit d531bc663d
31 changed files with 2046 additions and 0 deletions

4
.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,8 @@
Desktop
- links projektübersicht, projektdetails und areadetails (je nachdem was auf ist)
- rechts md dateien
Mobile
- projektübersicht, projektdetails und areadetails
- md dateien werden in der gleichen sicht geöffnet
- wischen nach rechts schließt sie und zeigt wieder die übersicht

3
doc/bereiche/area.md Normal file
View File

@@ -0,0 +1,3 @@
- container für features
- Auflistung als Columns
- wird gespeichert als ./{area}/

3
doc/bereiche/core.md Normal file
View File

@@ -0,0 +1,3 @@
- Max 64 Zeichen
- wird als ./core.md gespeichert
- beschreibt kompakt was das projekt ist

17
doc/bereiche/details.md Normal file
View File

@@ -0,0 +1,17 @@
- LMB auf area öffnet die areatdetails
- enthält details zur area
layout: features
features
- alle features zum area als cards
- LMB auf feature öffnet die {feature}.md
- RMB auf feature öffnet die feature optionen
feature option create
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons
feature option delete
- entfernt feature

3
doc/bereiche/feature.md Normal file
View File

@@ -0,0 +1,3 @@
- beschreibt eine funktionalität der area
- wie ein git commit
- wird gespeichert als ./{area}/{feature}.md

54
doc/bereiche/overview.md Normal file
View File

@@ -0,0 +1,54 @@
- LMB auf projekt öffnet die projektdetails
- enthält details zum projekt
layout
- core
- target
- story
- areas
core
- inhalt aus core.md
- oben als card
- LMB öffnet ./core.md
target
- inhalt aus ./target.md
- unter kern als card
- LMB öffnet target.md
story
- inhalt aus ./story.md
- unter target als card
- LMB öffnet story.md
- ist aufklappbar, standard ist eingeklappt
areas
- alle areas zum projekt als cards
- enthält die features als flex liste
- featurename ist dateiname
- RMB auf area öffnet optionen zur area
- LMB auf feature öffnet die {feature}.md
- RMB auf feature öffnet die feature optionen
area option create
- erstellt eine neuen area ordner
- modal für den area namen
- erstellen und abbrechen buttons
area option rename
- ändert den ordnernamen zur area
- modal mit dem aktuellen area namen
- speichern und abbrechen buttons
area option delete
- entfernt area rekursiv
- muss bestätigt werden
feature option create
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons
feature option delete
- entfernt feature

9
doc/bereiche/project.md Normal file
View File

@@ -0,0 +1,9 @@
- kann ich projekt oder subprojekt zu einem großen projekt sein
- ist abgeschlossen, also hat keine abhängigkeit
- ist der root ordner
- ordnername ist der projektname
kerndatei
- wird als project.md gespeichert
- enthält kern, zweck, stories und kategorien als md dateien
- enthält bereiche als ordner

28
doc/bereiche/projects.md Normal file
View File

@@ -0,0 +1,28 @@
- LMB auf plugin icon öffnet die projektübersicht
- enthält alle projekte
plugin icon
- 9 app icon
- icon links in obsidian navigation
projekte
- lädt alle ordner aus ./projects/
- cards als buttons
- grid layout
- name ist ordnername
- RMB auf Card öffnet optionen zum projekt
option create
- erstellt ein neues projekt
- erstellt automatisch core.md, target.md und story.md
- modal für den projektnamen
- erstellen und abbrechen buttons
option rename
- ändert den ordnernamen zum projekt
- modal mit dem aktuellen projektnamen
- speichern und abbrechen buttons
option delete
- entfernt projekt rekursiv
- muss bestätigt werden

5
doc/bereiche/story.md Normal file
View File

@@ -0,0 +1,5 @@
- beschreibt wie das fertige projekt in der regel verwendet werden soll
- Max 1024 Zeichen
- wird als ./story.md gespeichert
- nur die hauptanwendung
- abfolge von schritten (start ... ende)

6
doc/bereiche/target.md Normal file
View File

@@ -0,0 +1,6 @@
- beschreibt simpel welches problem das projekt löst
- Was ist das Problem und die Folge davon?
- Wie löst das Projekt das Problem und was ist die Folge davon?
- der zweck muss klar erkennbar sein
- max 255 zeichen
- wird als ./target.md gespeichert

1
doc/core.md Normal file
View File

@@ -0,0 +1 @@
Obsidian Plugin - Projektkontext managen

24
doc/project.md Normal file
View File

@@ -0,0 +1,24 @@
**Core**
- Obsidian Plugin - Projektkontext managen
**Target**
- Für große Projekte geht der Kontext schnell verloren.
- Sie werden unübersichtlich und schwer zu managen.
- Das Plugin hilft Projekte kontextbezogen zu strukturieren.
- So lassen sich große Projekte wieder managen.
**Story**
- Großes Projekt soll geplannt werden
- Core definieren (Worum geht es?)
- Target beschreiben (Welches Problem löst es?)
- Story schreiben (Wie wird es verwendet?)
- Areas und Features definieren (Welche Funktionen hat es?)
- Features implementieren
- Projekt v1
**Areas**
- **Core**: Kompakter Titel zum Projekt
- **Target**: Problem -> Folge -> Lösung -> Ergebnis zum Projekt
- **Story**: Kernverwendung vom Projekt
- **Area**: Sammlung von Features
- **Feature**: Abgeschlossene Funktion im Bereich
- **Projects**: Übersicht der Projekte
- **Overview**: Übersicht zu einem Projekt
- **Detail**: Übersicht zu einem Area

7
doc/story.md Normal file
View File

@@ -0,0 +1,7 @@
- Großes Projekt soll geplannt werden
- Core definieren (Worum geht es?)
- Target beschreiben (Welches Problem löst es?)
- Story schreiben (Wie wird es verwendet?)
- Areas und Features definieren (Welche Funktionen hat es?)
- Features implementieren
- Projekt v1

2
doc/target.md Normal file
View File

@@ -0,0 +1,2 @@
Für große Projekte geht der Kontext schnell verloren, sie werden unübersichtlich und schwer zu managen.
Das Plugin hilft Projekte kontextbezogen zu strukturieren, so lassen sich große Projekte wieder managen.

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

83
main.ts Normal file
View File

@@ -0,0 +1,83 @@
import { MarkdownView, Platform, Plugin, WorkspaceLeaf } from "obsidian";
import {
VIEW_TYPE_PROJECTS,
VIEW_TYPE_OVERVIEW,
VIEW_TYPE_DETAILS,
RIBBON_ICON,
} from "./src/const";
import { ProjectsView } from "./src/views/ProjectsView";
import { OverviewView } from "./src/views/OverviewView";
import { DetailsView } from "./src/views/DetailsView";
import { consumeMobileReturn } from "./src/ui";
const SWIPE_THRESHOLD = 80;
const SWIPE_MAX_VERTICAL = 50;
export default class ProjektkontextPlugin extends Plugin {
async onload(): Promise<void> {
this.registerView(VIEW_TYPE_PROJECTS, (leaf) => new ProjectsView(leaf));
this.registerView(VIEW_TYPE_OVERVIEW, (leaf) => new OverviewView(leaf));
this.registerView(VIEW_TYPE_DETAILS, (leaf) => new DetailsView(leaf));
this.addRibbonIcon(RIBBON_ICON, "Projekte", () => {
void this.activateProjectsView();
});
this.addCommand({
id: "open-projects",
name: "Projekte öffnen",
callback: () => void this.activateProjectsView(),
});
if (Platform.isMobile) this.installMobileSwipe();
}
async onunload(): Promise<void> {}
async activateProjectsView(): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_PROJECTS)[0] ?? null;
if (!leaf) {
leaf = workspace.getLeaf(false);
await leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true });
}
workspace.revealLeaf(leaf);
}
private installMobileSwipe(): void {
let startX = 0;
let startY = 0;
let active = false;
this.registerDomEvent(document, "touchstart", (ev: TouchEvent) => {
if (ev.touches.length !== 1) return;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return;
startX = ev.touches[0].clientX;
startY = ev.touches[0].clientY;
active = true;
});
this.registerDomEvent(document, "touchend", (ev: TouchEvent) => {
if (!active) return;
active = false;
const t = ev.changedTouches[0];
const dx = t.clientX - startX;
const dy = Math.abs(t.clientY - startY);
if (dx < SWIPE_THRESHOLD || dy > SWIPE_MAX_VERTICAL) return;
const target = consumeMobileReturn();
if (!target) return;
void this.restoreView(target.type, target.state);
});
}
private async restoreView(type: string, state: unknown): Promise<void> {
const leaf = this.app.workspace.getLeaf(false);
await leaf.setViewState({
type,
active: true,
state: (state as Record<string, unknown>) ?? {},
});
this.app.workspace.revealLeaf(leaf);
}
}

9
manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"id": "projektkontext",
"name": "Projektkontext",
"version": "0.1.0",
"minAppVersion": "1.4.0",
"description": "Projekte kontextbezogen strukturieren: Core, Target, Story, Areas und Features.",
"author": "marha",
"isDesktopOnly": false
}

611
package-lock.json generated Normal file
View File

@@ -0,0 +1,611 @@
{
"name": "projektkontext",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "projektkontext",
"version": "0.1.0",
"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"
}
},
"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/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/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/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
}
}
}

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "projektkontext",
"version": "0.1.0",
"description": "Obsidian Plugin - Projektkontext managen",
"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"
}
}

9
src/const.ts Normal file
View File

@@ -0,0 +1,9 @@
export const PROJECTS_ROOT = "projects";
export const VIEW_TYPE_PROJECTS = "projektkontext-projects";
export const VIEW_TYPE_OVERVIEW = "projektkontext-overview";
export const VIEW_TYPE_DETAILS = "projektkontext-details";
export const RIBBON_ICON = "layout-grid";
export const PROJECT_FILES = ["core.md", "target.md", "story.md"] as const;

99
src/fs.ts Normal file
View File

@@ -0,0 +1,99 @@
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 areaPath(project: string, area: string): string {
return normalizePath(`${PROJECTS_ROOT}/${project}/${area}`);
}
export function featurePath(project: string, area: string, feature: string): string {
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
return normalizePath(`${PROJECTS_ROOT}/${project}/${area}/${file}`);
}
export async function ensureFolder(app: App, path: string): Promise<void> {
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 p = normalizePath(path);
const folder = app.vault.getAbstractFileByPath(p);
if (!(folder instanceof TFolder)) return [];
return folder.children
.filter((c): c is TFolder => c instanceof TFolder)
.sort((a, b) => a.name.localeCompare(b.name));
}
export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] {
const p = normalizePath(path);
const folder = app.vault.getAbstractFileByPath(p);
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))
.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 createArea(app: App, project: string, area: string): Promise<void> {
await ensureFolder(app, areaPath(project, area));
}
export async function createFeature(
app: App,
project: string,
area: string,
feature: string,
): Promise<TFile> {
return await ensureFile(app, featurePath(project, area, feature), "");
}
export function isProjectRootFile(name: string): boolean {
return (PROJECT_FILES as readonly string[]).includes(name);
}

View File

@@ -0,0 +1,37 @@
import { App, Modal, Setting } from "obsidian";
export interface ConfirmModalOptions {
title: string;
message: string;
cta: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
}
export class ConfirmModal extends Modal {
private opts: ConfirmModalOptions;
constructor(app: App, opts: ConfirmModalOptions) {
super(app);
this.opts = opts;
}
onOpen(): void {
this.titleEl.setText(this.opts.title);
this.contentEl.createDiv({ cls: "pk-confirm-msg", text: this.opts.message });
new Setting(this.contentEl)
.addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close()))
.addButton((b) => {
b.setButtonText(this.opts.cta).setCta();
if (this.opts.destructive) b.setWarning();
b.onClick(async () => {
await this.opts.onConfirm();
this.close();
});
});
}
onClose(): void {
this.contentEl.empty();
}
}

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

117
src/ui.ts Normal file
View File

@@ -0,0 +1,117 @@
import { App, Menu, Platform, TFile, 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 interface MobileReturnTarget {
type: string;
state?: unknown;
}
let pendingReturn: MobileReturnTarget | null = null;
export function consumeMobileReturn(): MobileReturnTarget | null {
const r = pendingReturn;
pendingReturn = null;
return r;
}
export async function openMarkdown(
app: App,
path: string,
mobileReturn?: MobileReturnTarget,
): Promise<void> {
const p = normalizePath(path);
const f = app.vault.getAbstractFileByPath(p);
if (!(f instanceof TFile)) return;
if (Platform.isMobile) {
if (mobileReturn) pendingReturn = mobileReturn;
const leaf = app.workspace.getLeaf(false);
await leaf.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 leaf = app.workspace.getLeaf("split", "vertical");
await leaf.openFile(f);
}
}
export interface BreadcrumbSegment {
label: string;
onClick?: () => void;
}
export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement {
const wrap = parent.createDiv({ cls: "pk-breadcrumb" });
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 });
}
});
return wrap;
}
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 });
}

159
src/views/DetailsView.ts Normal file
View File

@@ -0,0 +1,159 @@
import { ItemView, WorkspaceLeaf, ViewStateResult } from "obsidian";
import {
VIEW_TYPE_DETAILS,
VIEW_TYPE_OVERVIEW,
VIEW_TYPE_PROJECTS,
RIBBON_ICON,
} from "../const";
import {
areaPath,
featurePath,
listMarkdownFiles,
createFeature,
deleteRecursive,
} from "../fs";
import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal";
export interface DetailsState extends Record<string, unknown> {
project: string;
area: string;
}
const NAME_RX = /^[^\\/:*?"<>|]+$/;
function validateName(name: string, taken: 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)) return "Name existiert bereits.";
return null;
}
export class DetailsView extends ItemView {
project = "";
area = "";
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_DETAILS;
}
getDisplayText(): string {
return this.area ? `Area: ${this.area}` : "Area";
}
getIcon(): string {
return RIBBON_ICON;
}
async setState(state: DetailsState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? "";
this.area = state?.area ?? "";
await super.setState(state, result);
await this.render();
}
getState(): DetailsState {
return { project: this.project, area: this.area };
}
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.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return;
ev.preventDefault();
menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreate() },
]);
});
}
async onClose(): Promise<void> {}
private async render(): Promise<void> {
const root = this.containerEl.children[1] as HTMLElement;
root.empty();
root.addClass("pk-root");
if (!this.project || !this.area) {
emptyState(root, "Keine Area ausgewählt.");
return;
}
breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectsView() },
{ label: this.project, onClick: () => this.openOverview() },
{ label: this.area },
]);
const features = listMarkdownFiles(this.app, areaPath(this.project, this.area), []);
if (features.length === 0) {
emptyState(root, "Noch keine Features. Rechtsklick → Neues Feature.");
return;
}
const grid = root.createDiv({ cls: "pk-grid" });
for (const f of features) {
card(grid, {
title: f.basename,
onClick: () =>
openMarkdown(this.app, f.path, {
type: VIEW_TYPE_DETAILS,
state: { project: this.project, area: this.area },
}),
onContextMenu: (ev) =>
menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) },
]),
});
}
}
private openCreate(): void {
const taken = listMarkdownFiles(this.app, areaPath(this.project, this.area), []).map(
(f) => f.basename,
);
new NameModal(this.app, {
title: "Neues Feature",
label: "Feature-Name",
cta: "Erstellen",
validate: (n) => validateName(n, taken),
onSubmit: async (name) => {
await createFeature(this.app, this.project, this.area, name);
await this.render();
},
}).open();
}
private openDelete(feature: string): void {
new ConfirmModal(this.app, {
title: "Feature löschen",
message: `Feature „${feature}" wird gelöscht. Fortfahren?`,
cta: "Löschen",
destructive: true,
onConfirm: async () => {
await deleteRecursive(this.app, featurePath(this.project, this.area, feature));
await this.render();
},
}).open();
}
private async openOverview(): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_OVERVIEW,
active: true,
state: { project: this.project },
});
}
private async openProjectsView(): Promise<void> {
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true });
}
}

295
src/views/OverviewView.ts Normal file
View File

@@ -0,0 +1,295 @@
import { ItemView, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
import {
VIEW_TYPE_OVERVIEW,
VIEW_TYPE_DETAILS,
VIEW_TYPE_PROJECTS,
RIBBON_ICON,
} from "../const";
import {
projectPath,
areaPath,
featurePath,
listFolders,
listMarkdownFiles,
readFile,
rename,
deleteRecursive,
createArea,
createFeature,
} from "../fs";
import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal";
export interface OverviewState extends Record<string, unknown> {
project: string;
}
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 OverviewView extends ItemView {
project = "";
private storyOpen = false;
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_OVERVIEW;
}
getDisplayText(): string {
return this.project ? `Projekt: ${this.project}` : "Projekt";
}
getIcon(): string {
return RIBBON_ICON;
}
async setState(state: OverviewState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? "";
await super.setState(state, result);
await this.render();
}
getState(): OverviewState {
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> {}
private async render(): Promise<void> {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement;
if (!this.project) {
root.empty();
root.addClass("pk-root");
emptyState(root, "Kein Projekt ausgewählt.");
return;
}
const projRoot = projectPath(this.project);
const corePath = normalizePath(`${projRoot}/core.md`);
const targetPath = normalizePath(`${projRoot}/target.md`);
const storyPath = normalizePath(`${projRoot}/story.md`);
const [core, target, story] = await Promise.all([
readFile(this.app, corePath),
readFile(this.app, targetPath),
readFile(this.app, storyPath),
]);
if (token !== this.renderToken) return;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectsView() },
{ label: this.project },
]);
const stack = root.createDiv({ cls: "pk-stack" });
this.renderInfoCard(stack, "Core", core, corePath);
this.renderInfoCard(stack, "Target", target, targetPath);
this.renderStoryCard(stack, story, storyPath);
this.renderAreas(root);
}
private renderInfoCard(parent: HTMLElement, title: string, content: string, path: string): void {
card(parent, {
title,
body: content || "(leer)",
onClick: () =>
openMarkdown(this.app, path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
}),
});
}
private renderStoryCard(parent: HTMLElement, content: string, path: string): void {
const el = parent.createDiv({ cls: "pk-card" });
const header = el.createDiv({ cls: "pk-card-header pk-clickable pk-collapsible-toggle" });
header.setText(`${this.storyOpen ? "▾" : "▸"} Story`);
header.addEventListener("click", () => {
this.storyOpen = !this.storyOpen;
void this.render();
});
if (this.storyOpen) {
const body = el.createDiv({ cls: "pk-card-body pk-clickable" });
body.setText(content || "(leer)");
body.addEventListener("click", () =>
openMarkdown(this.app, path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
}),
);
}
}
private renderAreas(parent: HTMLElement): void {
const section = parent.createDiv({ cls: "pk-areas-section" });
section.createDiv({ cls: "pk-section-title", text: "Areas" });
section.addEventListener("contextmenu", (ev) => {
if (ev.defaultPrevented) return;
ev.preventDefault();
menu(ev, [
{ title: "Neue Area", icon: "plus", onClick: () => this.openCreateArea() },
]);
});
const areas = listFolders(this.app, projectPath(this.project));
if (areas.length === 0) {
emptyState(section, "Noch keine Areas. Rechtsklick → Neue Area.");
return;
}
const takenAreas = areas.map((a) => a.name);
const stack = section.createDiv({ cls: "pk-areas-flex" });
for (const area of areas) {
const features = listMarkdownFiles(
this.app,
areaPath(this.project, area.name),
[],
);
card(stack, {
title: area.name,
cls: "pk-area-card",
onClick: () => this.openDetails(area.name),
onContextMenu: (ev) =>
menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) },
{ title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) },
{ title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) },
]),
body: (body) => {
if (features.length === 0) {
body.createDiv({ cls: "pk-empty", text: "Keine Features." });
return;
}
const list = body.createDiv({ cls: "pk-features" });
for (const f of features) {
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
chip.addEventListener("click", (ev) => {
ev.stopPropagation();
openMarkdown(this.app, f.path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
});
});
chip.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteFeature(area.name, f.basename) },
]);
});
}
},
});
}
}
private openCreateArea(): void {
const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name);
new NameModal(this.app, {
title: "Neue Area",
label: "Area-Name",
cta: "Erstellen",
validate: (n) => validateName(n, taken),
onSubmit: async (name) => {
await createArea(this.app, this.project, name);
await this.render();
},
}).open();
}
private openRenameArea(current: string, taken: string[]): void {
new NameModal(this.app, {
title: "Area umbenennen",
label: "Area-Name",
initial: current,
cta: "Speichern",
validate: (n) => validateName(n, taken, current),
onSubmit: async (name) => {
if (name === current) return;
await rename(
this.app,
areaPath(this.project, current),
areaPath(this.project, name),
);
await this.render();
},
}).open();
}
private openDeleteArea(name: string): void {
new ConfirmModal(this.app, {
title: "Area löschen",
message: `Area „${name}" wird mit allen Features rekursiv gelöscht. Fortfahren?`,
cta: "Löschen",
destructive: true,
onConfirm: async () => {
await deleteRecursive(this.app, areaPath(this.project, name));
await this.render();
},
}).open();
}
private openCreateFeature(area: string): void {
const taken = listMarkdownFiles(this.app, areaPath(this.project, area), []).map((f) => f.basename);
new NameModal(this.app, {
title: "Neues Feature",
label: "Feature-Name",
cta: "Erstellen",
validate: (n) => validateName(n, taken),
onSubmit: async (name) => {
await createFeature(this.app, this.project, area, name);
await this.render();
},
}).open();
}
private openDeleteFeature(area: string, feature: string): void {
new ConfirmModal(this.app, {
title: "Feature löschen",
message: `Feature „${feature}" wird gelöscht. Fortfahren?`,
cta: "Löschen",
destructive: true,
onConfirm: async () => {
await deleteRecursive(this.app, featurePath(this.project, area, feature));
await this.render();
},
}).open();
}
private async openDetails(area: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_DETAILS,
active: true,
state: { project: this.project, area },
});
}
private async openProjectsView(): Promise<void> {
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true });
}
}

149
src/views/ProjectsView.ts Normal file
View File

@@ -0,0 +1,149 @@
import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } from "../const";
import {
projectsPath,
projectPath,
listFolders,
ensureFolder,
createProject,
rename,
deleteRecursive,
} from "../fs";
import { card, menu, breadcrumb, emptyState } from "../ui";
import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal";
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 ProjectsView extends ItemView {
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_PROJECTS;
}
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.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 root = this.containerEl.children[1] as HTMLElement;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [{ label: "Projekte" }]);
const projects = listFolders(this.app, projectsPath());
if (projects.length === 0) {
emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt.");
return;
}
const taken = projects.map((p) => p.name);
const grid = root.createDiv({ cls: "pk-grid" });
for (const proj of projects) {
card(grid, {
title: proj.name,
onClick: () => this.openOverview(proj.name),
onContextMenu: (ev) =>
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 openDelete(name: string): void {
new ConfirmModal(this.app, {
title: "Projekt löschen",
message: `Projekt „${name}" wird mitsamt aller Areas und Features rekursiv gelöscht. Fortfahren?`,
cta: "Löschen",
destructive: true,
onConfirm: async () => {
try {
await deleteRecursive(this.app, projectPath(name));
await this.render();
} catch (e) {
new Notice(`Fehler: ${(e as Error).message}`);
}
},
}).open();
}
private async openOverview(name: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_OVERVIEW,
active: true,
state: { project: name },
});
}
}

153
styles.css Normal file
View File

@@ -0,0 +1,153 @@
.pk-root {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.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-stack {
display: flex;
flex-direction: column;
gap: 10px;
}
.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-features {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pk-feature-chip {
padding: 3px 8px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
}
.pk-feature-chip:hover {
background: var(--background-modifier-hover);
}
.pk-plus {
align-self: flex-start;
padding: 4px 10px;
cursor: pointer;
}
.pk-empty {
color: var(--text-muted);
padding: 20px;
text-align: center;
}
.pk-collapsible-toggle {
cursor: pointer;
user-select: none;
}
.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-areas-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.pk-areas-flex {
column-width: 250px;
column-gap: 10px;
}
.pk-area-card {
break-inside: avoid;
margin-bottom: 10px;
}

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