commit d531bc663d3fb297a0f4fe1c48f1ab04e109c3ab Author: Marek Date: Thu Apr 30 20:10:14 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74de0aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +main.js +*.log +.DS_Store diff --git a/doc/bereiche/allgemein.md b/doc/bereiche/allgemein.md new file mode 100644 index 0000000..0a111ca --- /dev/null +++ b/doc/bereiche/allgemein.md @@ -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 diff --git a/doc/bereiche/area.md b/doc/bereiche/area.md new file mode 100644 index 0000000..e695f4e --- /dev/null +++ b/doc/bereiche/area.md @@ -0,0 +1,3 @@ +- container für features +- Auflistung als Columns +- wird gespeichert als ./{area}/ diff --git a/doc/bereiche/core.md b/doc/bereiche/core.md new file mode 100644 index 0000000..c6732fd --- /dev/null +++ b/doc/bereiche/core.md @@ -0,0 +1,3 @@ +- Max 64 Zeichen +- wird als ./core.md gespeichert +- beschreibt kompakt was das projekt ist diff --git a/doc/bereiche/details.md b/doc/bereiche/details.md new file mode 100644 index 0000000..7294403 --- /dev/null +++ b/doc/bereiche/details.md @@ -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 diff --git a/doc/bereiche/feature.md b/doc/bereiche/feature.md new file mode 100644 index 0000000..1fe76ce --- /dev/null +++ b/doc/bereiche/feature.md @@ -0,0 +1,3 @@ +- beschreibt eine funktionalität der area +- wie ein git commit +- wird gespeichert als ./{area}/{feature}.md \ No newline at end of file diff --git a/doc/bereiche/overview.md b/doc/bereiche/overview.md new file mode 100644 index 0000000..7ed6350 --- /dev/null +++ b/doc/bereiche/overview.md @@ -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 diff --git a/doc/bereiche/project.md b/doc/bereiche/project.md new file mode 100644 index 0000000..db40996 --- /dev/null +++ b/doc/bereiche/project.md @@ -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 diff --git a/doc/bereiche/projects.md b/doc/bereiche/projects.md new file mode 100644 index 0000000..df4b305 --- /dev/null +++ b/doc/bereiche/projects.md @@ -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 \ No newline at end of file diff --git a/doc/bereiche/story.md b/doc/bereiche/story.md new file mode 100644 index 0000000..abea003 --- /dev/null +++ b/doc/bereiche/story.md @@ -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) \ No newline at end of file diff --git a/doc/bereiche/target.md b/doc/bereiche/target.md new file mode 100644 index 0000000..bd0557f --- /dev/null +++ b/doc/bereiche/target.md @@ -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 diff --git a/doc/core.md b/doc/core.md new file mode 100644 index 0000000..68c71ec --- /dev/null +++ b/doc/core.md @@ -0,0 +1 @@ +Obsidian Plugin - Projektkontext managen \ No newline at end of file diff --git a/doc/project.md b/doc/project.md new file mode 100644 index 0000000..82e9666 --- /dev/null +++ b/doc/project.md @@ -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 diff --git a/doc/story.md b/doc/story.md new file mode 100644 index 0000000..71bb8ba --- /dev/null +++ b/doc/story.md @@ -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 \ No newline at end of file diff --git a/doc/target.md b/doc/target.md new file mode 100644 index 0000000..a8c26d2 --- /dev/null +++ b/doc/target.md @@ -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. \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..245c469 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,40 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const ctx = await esbuild.context({ + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2020", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await ctx.rebuild(); + process.exit(0); +} else { + await ctx.watch(); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..7518c98 --- /dev/null +++ b/main.ts @@ -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 { + 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 {} + + async activateProjectsView(): Promise { + 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 { + const leaf = this.app.workspace.getLeaf(false); + await leaf.setViewState({ + type, + active: true, + state: (state as Record) ?? {}, + }); + this.app.workspace.revealLeaf(leaf); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..81f3d71 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..270d511 --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..71b5ad9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..d4eb50b --- /dev/null +++ b/src/const.ts @@ -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; diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..24f076a --- /dev/null +++ b/src/fs.ts @@ -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 { + const p = normalizePath(path); + const exists = app.vault.getAbstractFileByPath(p); + if (!exists) { + await app.vault.createFolder(p); + } +} + +export async function ensureFile(app: App, path: string, content = ""): Promise { + const p = normalizePath(path); + const existing = app.vault.getAbstractFileByPath(p); + if (existing instanceof TFile) return existing; + return await app.vault.create(p, content); +} + +export function listFolders(app: App, path: string): TFolder[] { + const 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 { + const p = normalizePath(path); + const f = app.vault.getAbstractFileByPath(p); + if (!(f instanceof TFile)) return ""; + return await app.vault.read(f); +} + +export async function deleteRecursive(app: App, path: string): Promise { + const p = normalizePath(path); + const f = app.vault.getAbstractFileByPath(p); + if (!f) return; + await app.vault.delete(f, true); +} + +export async function rename(app: App, oldPath: string, newPath: string): Promise { + const f = app.vault.getAbstractFileByPath(normalizePath(oldPath)); + if (!f) return; + await app.fileManager.renameFile(f, normalizePath(newPath)); +} + +export async function createProject(app: App, name: string): Promise { + const root = projectPath(name); + await ensureFolder(app, projectsPath()); + await ensureFolder(app, root); + for (const file of PROJECT_FILES) { + await ensureFile(app, normalizePath(`${root}/${file}`), ""); + } +} + +export async function createArea(app: App, project: string, area: string): Promise { + await ensureFolder(app, areaPath(project, area)); +} + +export async function createFeature( + app: App, + project: string, + area: string, + feature: string, +): Promise { + return await ensureFile(app, featurePath(project, area, feature), ""); +} + +export function isProjectRootFile(name: string): boolean { + return (PROJECT_FILES as readonly string[]).includes(name); +} diff --git a/src/modals/ConfirmModal.ts b/src/modals/ConfirmModal.ts new file mode 100644 index 0000000..b385705 --- /dev/null +++ b/src/modals/ConfirmModal.ts @@ -0,0 +1,37 @@ +import { App, Modal, Setting } from "obsidian"; + +export interface ConfirmModalOptions { + title: string; + message: string; + cta: string; + destructive?: boolean; + onConfirm: () => void | Promise; +} + +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(); + } +} diff --git a/src/modals/NameModal.ts b/src/modals/NameModal.ts new file mode 100644 index 0000000..e7c6ba8 --- /dev/null +++ b/src/modals/NameModal.ts @@ -0,0 +1,68 @@ +import { App, Modal, Setting } from "obsidian"; + +export interface NameModalOptions { + title: string; + label?: string; + initial?: string; + cta: string; + validate?: (name: string) => string | null; + onSubmit: (name: string) => void | Promise; +} + +export class NameModal extends Modal { + private opts: NameModalOptions; + private value: string; + + constructor(app: App, opts: NameModalOptions) { + super(app); + this.opts = opts; + this.value = opts.initial ?? ""; + } + + onOpen(): void { + this.titleEl.setText(this.opts.title); + const setting = new Setting(this.contentEl) + .setName(this.opts.label ?? "Name") + .addText((t) => + t.setValue(this.value).onChange((v) => { + this.value = v; + }), + ); + const input = setting.controlEl.querySelector("input"); + input?.focus(); + input?.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + this.submit(); + } + }); + new Setting(this.contentEl) + .addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close())) + .addButton((b) => + b + .setButtonText(this.opts.cta) + .setCta() + .onClick(() => this.submit()), + ); + } + + private async submit(): Promise { + const name = this.value.trim(); + if (!name) return; + if (this.opts.validate) { + const err = this.opts.validate(name); + if (err) { + const existing = this.contentEl.querySelector(".pk-modal-error"); + if (existing) existing.remove(); + this.contentEl.createDiv({ cls: "pk-modal-error", text: err }); + return; + } + } + await this.opts.onSubmit(name); + this.close(); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..614b22e --- /dev/null +++ b/src/ui.ts @@ -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 { + 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 }); +} diff --git a/src/views/DetailsView.ts b/src/views/DetailsView.ts new file mode 100644 index 0000000..cce343b --- /dev/null +++ b/src/views/DetailsView.ts @@ -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 { + 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 { + 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 { + 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 {} + + private async render(): Promise { + 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 { + await this.leaf.setViewState({ + type: VIEW_TYPE_OVERVIEW, + active: true, + state: { project: this.project }, + }); + } + + private async openProjectsView(): Promise { + await this.leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true }); + } +} diff --git a/src/views/OverviewView.ts b/src/views/OverviewView.ts new file mode 100644 index 0000000..6b4a6ff --- /dev/null +++ b/src/views/OverviewView.ts @@ -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 { + 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 { + this.project = state?.project ?? ""; + await super.setState(state, result); + await this.render(); + } + + getState(): OverviewState { + return { project: this.project }; + } + + async onOpen(): Promise { + await this.render(); + this.registerEvent(this.app.vault.on("create", () => this.render())); + this.registerEvent(this.app.vault.on("delete", () => this.render())); + this.registerEvent(this.app.vault.on("rename", () => this.render())); + this.registerEvent(this.app.vault.on("modify", (f) => { + if (f.path.startsWith(projectPath(this.project) + "/")) this.render(); + })); + } + + async onClose(): Promise {} + + private async render(): Promise { + 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 { + await this.leaf.setViewState({ + type: VIEW_TYPE_DETAILS, + active: true, + state: { project: this.project, area }, + }); + } + + private async openProjectsView(): Promise { + await this.leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true }); + } +} diff --git a/src/views/ProjectsView.ts b/src/views/ProjectsView.ts new file mode 100644 index 0000000..c93d662 --- /dev/null +++ b/src/views/ProjectsView.ts @@ -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 { + 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 {} + + private async render(): Promise { + 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 { + await this.leaf.setViewState({ + type: VIEW_TYPE_OVERVIEW, + active: true, + state: { project: name }, + }); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f828b8d --- /dev/null +++ b/styles.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0410e27 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2020", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["DOM", "ES2020"] + }, + "include": ["main.ts", "src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +}