init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
main.js
|
||||
*.log
|
||||
.DS_Store
|
||||
8
doc/bereiche/allgemein.md
Normal file
8
doc/bereiche/allgemein.md
Normal 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
3
doc/bereiche/area.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- container für features
|
||||
- Auflistung als Columns
|
||||
- wird gespeichert als ./{area}/
|
||||
3
doc/bereiche/core.md
Normal file
3
doc/bereiche/core.md
Normal 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
17
doc/bereiche/details.md
Normal 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
3
doc/bereiche/feature.md
Normal 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
54
doc/bereiche/overview.md
Normal 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
9
doc/bereiche/project.md
Normal 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
28
doc/bereiche/projects.md
Normal 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
5
doc/bereiche/story.md
Normal 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
6
doc/bereiche/target.md
Normal 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
1
doc/core.md
Normal file
@@ -0,0 +1 @@
|
||||
Obsidian Plugin - Projektkontext managen
|
||||
24
doc/project.md
Normal file
24
doc/project.md
Normal 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
7
doc/story.md
Normal 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
2
doc/target.md
Normal 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
40
esbuild.config.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2020",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await ctx.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await ctx.watch();
|
||||
}
|
||||
83
main.ts
Normal file
83
main.ts
Normal 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
9
manifest.json
Normal 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
611
package-lock.json
generated
Normal 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
21
package.json
Normal 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
9
src/const.ts
Normal 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
99
src/fs.ts
Normal 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);
|
||||
}
|
||||
37
src/modals/ConfirmModal.ts
Normal file
37
src/modals/ConfirmModal.ts
Normal 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
68
src/modals/NameModal.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { App, Modal, Setting } from "obsidian";
|
||||
|
||||
export interface NameModalOptions {
|
||||
title: string;
|
||||
label?: string;
|
||||
initial?: string;
|
||||
cta: string;
|
||||
validate?: (name: string) => string | null;
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class NameModal extends Modal {
|
||||
private opts: NameModalOptions;
|
||||
private value: string;
|
||||
|
||||
constructor(app: App, opts: NameModalOptions) {
|
||||
super(app);
|
||||
this.opts = opts;
|
||||
this.value = opts.initial ?? "";
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText(this.opts.title);
|
||||
const setting = new Setting(this.contentEl)
|
||||
.setName(this.opts.label ?? "Name")
|
||||
.addText((t) =>
|
||||
t.setValue(this.value).onChange((v) => {
|
||||
this.value = v;
|
||||
}),
|
||||
);
|
||||
const input = setting.controlEl.querySelector("input");
|
||||
input?.focus();
|
||||
input?.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
new Setting(this.contentEl)
|
||||
.addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close()))
|
||||
.addButton((b) =>
|
||||
b
|
||||
.setButtonText(this.opts.cta)
|
||||
.setCta()
|
||||
.onClick(() => this.submit()),
|
||||
);
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
const name = this.value.trim();
|
||||
if (!name) return;
|
||||
if (this.opts.validate) {
|
||||
const err = this.opts.validate(name);
|
||||
if (err) {
|
||||
const existing = this.contentEl.querySelector(".pk-modal-error");
|
||||
if (existing) existing.remove();
|
||||
this.contentEl.createDiv({ cls: "pk-modal-error", text: err });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.opts.onSubmit(name);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
117
src/ui.ts
Normal file
117
src/ui.ts
Normal 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
159
src/views/DetailsView.ts
Normal 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
295
src/views/OverviewView.ts
Normal 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
149
src/views/ProjectsView.ts
Normal 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
153
styles.css
Normal 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
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["DOM", "ES2020"]
|
||||
},
|
||||
"include": ["main.ts", "src/**/*.ts"]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"0.1.0": "1.4.0"
|
||||
}
|
||||
Reference in New Issue
Block a user