Compare commits
6 Commits
6ea56fb5e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f580b55a | ||
|
|
e35ad61b2d | ||
| b98a998689 | |||
| ed26305193 | |||
| 27f7d14d4f | |||
| dddbdeb616 |
@@ -1 +0,0 @@
|
|||||||
Obsidian Plugin - Projektkontext managen
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
**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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
- 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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- container für features
|
|
||||||
- Auflistung als Columns
|
|
||||||
- wird gespeichert als `./{area}/`
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Max 64 Zeichen
|
|
||||||
- wird als `./_core.md` gespeichert
|
|
||||||
- beschreibt kompakt was das projekt ist
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
- 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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- beschreibt eine funktionalität der area
|
|
||||||
- wie ein git commit
|
|
||||||
- wird gespeichert als `./{area}/{feature}.md`
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
- 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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
- 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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
- 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
- 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
|
|
||||||
132
main.ts
132
main.ts
@@ -1,83 +1,115 @@
|
|||||||
import { MarkdownView, Platform, Plugin, WorkspaceLeaf } from "obsidian";
|
import { MarkdownView, Platform, Plugin, WorkspaceLeaf } from "obsidian";
|
||||||
import {
|
import {
|
||||||
VIEW_TYPE_PROJECTS,
|
VIEW_TYPE_PROJECT_VIEW,
|
||||||
VIEW_TYPE_OVERVIEW,
|
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
VIEW_TYPE_DETAILS,
|
VIEW_TYPE_COLLECTION_VIEW,
|
||||||
RIBBON_ICON,
|
RIBBON_ICON,
|
||||||
} from "./src/const";
|
} from "./src/const";
|
||||||
import { ProjectsView } from "./src/views/ProjectsView";
|
import { ProjectView } from "./src/views/ProjectView";
|
||||||
import { OverviewView } from "./src/views/OverviewView";
|
import { ProjectDetailsView } from "./src/views/ProjectDetailsView";
|
||||||
import { DetailsView } from "./src/views/DetailsView";
|
import { CollectionView } from "./src/views/CollectionView";
|
||||||
import { consumeMobileReturn } from "./src/ui";
|
import { parseProjectFilePath } from "./src/fs";
|
||||||
|
import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui";
|
||||||
const SWIPE_THRESHOLD = 80;
|
|
||||||
const SWIPE_MAX_VERTICAL = 50;
|
|
||||||
|
|
||||||
export default class ProjektkontextPlugin extends Plugin {
|
export default class ProjektkontextPlugin extends Plugin {
|
||||||
async onload(): Promise<void> {
|
async onload(): Promise<void> {
|
||||||
this.registerView(VIEW_TYPE_PROJECTS, (leaf) => new ProjectsView(leaf));
|
this.registerView(VIEW_TYPE_PROJECT_VIEW, (leaf) => new ProjectView(leaf));
|
||||||
this.registerView(VIEW_TYPE_OVERVIEW, (leaf) => new OverviewView(leaf));
|
this.registerView(VIEW_TYPE_PROJECT_DETAILS_VIEW, (leaf) => new ProjectDetailsView(leaf));
|
||||||
this.registerView(VIEW_TYPE_DETAILS, (leaf) => new DetailsView(leaf));
|
this.registerView(VIEW_TYPE_COLLECTION_VIEW, (leaf) => new CollectionView(leaf));
|
||||||
|
|
||||||
|
this.registerObsidianProtocolHandler("projektkontext", async (params) => {
|
||||||
|
const project = params.project;
|
||||||
|
if (!project) {
|
||||||
|
await this.activateProjectView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leaf = this.app.workspace.getLeaf(false);
|
||||||
|
await leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
|
active: true,
|
||||||
|
state: { project },
|
||||||
|
});
|
||||||
|
this.app.workspace.revealLeaf(leaf);
|
||||||
|
});
|
||||||
|
|
||||||
this.addRibbonIcon(RIBBON_ICON, "Projekte", () => {
|
this.addRibbonIcon(RIBBON_ICON, "Projekte", () => {
|
||||||
void this.activateProjectsView();
|
void this.activateProjectView();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "open-projects",
|
id: "open-projects",
|
||||||
name: "Projekte öffnen",
|
name: "Projekte öffnen",
|
||||||
callback: () => void this.activateProjectsView(),
|
callback: () => void this.activateProjectView(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Platform.isMobile) this.installMobileSwipe();
|
if (Platform.isMobile) {
|
||||||
|
const reattach = () => this.reattachMobileBreadcrumbs();
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on("file-open", () => requestAnimationFrame(reattach)),
|
||||||
|
);
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on("active-leaf-change", () => requestAnimationFrame(reattach)),
|
||||||
|
);
|
||||||
|
this.registerEvent(this.app.workspace.on("layout-change", reattach));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onunload(): Promise<void> {}
|
async onunload(): Promise<void> {}
|
||||||
|
|
||||||
async activateProjectsView(): Promise<void> {
|
async activateProjectView(): Promise<void> {
|
||||||
const { workspace } = this.app;
|
const { workspace } = this.app;
|
||||||
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_PROJECTS)[0] ?? null;
|
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_PROJECT_VIEW)[0] ?? null;
|
||||||
if (!leaf) {
|
if (!leaf) {
|
||||||
leaf = workspace.getLeaf(false);
|
leaf = workspace.getLeaf(false);
|
||||||
await leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true });
|
await leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||||
}
|
}
|
||||||
workspace.revealLeaf(leaf);
|
workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
|
|
||||||
private installMobileSwipe(): void {
|
private reattachMobileBreadcrumbs(): void {
|
||||||
let startX = 0;
|
this.app.workspace.iterateRootLeaves((leaf) => this.applyMobileBreadcrumb(leaf));
|
||||||
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> {
|
private applyMobileBreadcrumb(leaf: WorkspaceLeaf): void {
|
||||||
const leaf = this.app.workspace.getLeaf(false);
|
const view = leaf.view;
|
||||||
await leaf.setViewState({
|
if (!(view instanceof MarkdownView)) return;
|
||||||
type,
|
const file = view.file;
|
||||||
|
if (!file) {
|
||||||
|
injectMobileBreadcrumb(view, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = parseProjectFilePath(file.path);
|
||||||
|
if (!loc) {
|
||||||
|
injectMobileBreadcrumb(view, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const segments: BreadcrumbSegment[] = [
|
||||||
|
{
|
||||||
|
label: "Projekte",
|
||||||
|
onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: loc.project,
|
||||||
|
onClick: () =>
|
||||||
|
void leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
active: true,
|
active: true,
|
||||||
state: (state as Record<string, unknown>) ?? {},
|
state: { project: loc.project },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (loc.collection) {
|
||||||
|
segments.push({
|
||||||
|
label: loc.collection,
|
||||||
|
onClick: () =>
|
||||||
|
void leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_COLLECTION_VIEW,
|
||||||
|
active: true,
|
||||||
|
state: { project: loc.project, collection: loc.collection, zone: loc.zone },
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
this.app.workspace.revealLeaf(leaf);
|
}
|
||||||
|
segments.push({ label: file.basename });
|
||||||
|
injectMobileBreadcrumb(view, segments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/const.ts
16
src/const.ts
@@ -1,14 +1,14 @@
|
|||||||
export const PROJECTS_ROOT = "projects";
|
export const PROJECTS_ROOT = "";
|
||||||
|
export const TO_UPDATE_DIR = "_to-update";
|
||||||
|
export const IDEAS_DIR = "_ideas";
|
||||||
|
|
||||||
export const VIEW_TYPE_PROJECTS = "projektkontext-projects";
|
export const VIEW_TYPE_PROJECT_VIEW = "projektkontext-projects";
|
||||||
export const VIEW_TYPE_OVERVIEW = "projektkontext-overview";
|
export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "projektkontext-overview";
|
||||||
export const VIEW_TYPE_DETAILS = "projektkontext-details";
|
export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details";
|
||||||
|
|
||||||
export const RIBBON_ICON = "layout-grid";
|
export const RIBBON_ICON = "layout-grid";
|
||||||
|
|
||||||
export const PROJECT_FILE = "_project.md";
|
|
||||||
export const CORE_FILE = "_core.md";
|
export const CORE_FILE = "_core.md";
|
||||||
export const TARGET_FILE = "_target.md";
|
export const DESCRIPTION_FILE = "_description.md";
|
||||||
export const STORY_FILE = "_story.md";
|
|
||||||
|
|
||||||
export const PROJECT_FILES = [PROJECT_FILE, CORE_FILE, TARGET_FILE, STORY_FILE] as const;
|
export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const;
|
||||||
|
|||||||
98
src/fs.ts
98
src/fs.ts
@@ -1,5 +1,7 @@
|
|||||||
import { App, TFile, TFolder, normalizePath } from "obsidian";
|
import { App, TFile, TFolder, normalizePath } from "obsidian";
|
||||||
import { PROJECTS_ROOT, PROJECT_FILES } from "./const";
|
import { PROJECTS_ROOT, PROJECT_FILES, TO_UPDATE_DIR, IDEAS_DIR } from "./const";
|
||||||
|
|
||||||
|
export type Zone = "ready" | "to-update" | "ideas";
|
||||||
|
|
||||||
export function projectsPath(): string {
|
export function projectsPath(): string {
|
||||||
return PROJECTS_ROOT;
|
return PROJECTS_ROOT;
|
||||||
@@ -9,16 +11,36 @@ export function projectPath(name: string): string {
|
|||||||
return normalizePath(`${PROJECTS_ROOT}/${name}`);
|
return normalizePath(`${PROJECTS_ROOT}/${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function areaPath(project: string, area: string): string {
|
export function toUpdatePath(project: string): string {
|
||||||
return normalizePath(`${PROJECTS_ROOT}/${project}/${area}`);
|
return normalizePath(`${PROJECTS_ROOT}/${project}/${TO_UPDATE_DIR}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function featurePath(project: string, area: string, feature: string): string {
|
export function ideasPath(project: string): string {
|
||||||
|
return normalizePath(`${PROJECTS_ROOT}/${project}/${IDEAS_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoneRootPath(project: string, zone: Zone): string {
|
||||||
|
if (zone === "ready") return projectPath(project);
|
||||||
|
if (zone === "to-update") return toUpdatePath(project);
|
||||||
|
return ideasPath(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectionPath(project: string, collection: string, zone: Zone = "ready"): string {
|
||||||
|
return normalizePath(`${zoneRootPath(project, zone)}/${collection}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featurePath(
|
||||||
|
project: string,
|
||||||
|
collection: string,
|
||||||
|
feature: string,
|
||||||
|
zone: Zone = "ready",
|
||||||
|
): string {
|
||||||
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
|
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
|
||||||
return normalizePath(`${PROJECTS_ROOT}/${project}/${area}/${file}`);
|
return normalizePath(`${zoneRootPath(project, zone)}/${collection}/${file}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureFolder(app: App, path: string): Promise<void> {
|
export async function ensureFolder(app: App, path: string): Promise<void> {
|
||||||
|
if (!path) return;
|
||||||
const p = normalizePath(path);
|
const p = normalizePath(path);
|
||||||
const exists = app.vault.getAbstractFileByPath(p);
|
const exists = app.vault.getAbstractFileByPath(p);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -34,21 +56,21 @@ export async function ensureFile(app: App, path: string, content = ""): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listFolders(app: App, path: string): TFolder[] {
|
export function listFolders(app: App, path: string): TFolder[] {
|
||||||
const p = normalizePath(path);
|
const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot();
|
||||||
const folder = app.vault.getAbstractFileByPath(p);
|
|
||||||
if (!(folder instanceof TFolder)) return [];
|
if (!(folder instanceof TFolder)) return [];
|
||||||
return folder.children
|
return folder.children
|
||||||
.filter((c): c is TFolder => c instanceof TFolder)
|
.filter((c): c is TFolder => c instanceof TFolder)
|
||||||
|
.filter((c) => !c.name.startsWith("_") || c.name.startsWith("__"))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] {
|
export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] {
|
||||||
const p = normalizePath(path);
|
const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot();
|
||||||
const folder = app.vault.getAbstractFileByPath(p);
|
|
||||||
if (!(folder instanceof TFolder)) return [];
|
if (!(folder instanceof TFolder)) return [];
|
||||||
return folder.children
|
return folder.children
|
||||||
.filter((c): c is TFile => c instanceof TFile && c.extension === "md")
|
.filter((c): c is TFile => c instanceof TFile && c.extension === "md")
|
||||||
.filter((f) => !exclude.includes(f.name))
|
.filter((f) => !exclude.includes(f.name))
|
||||||
|
.filter((f) => !f.basename.startsWith("_") || f.basename.startsWith("__"))
|
||||||
.sort((a, b) => a.basename.localeCompare(b.basename));
|
.sort((a, b) => a.basename.localeCompare(b.basename));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,34 +101,74 @@ export async function createProject(app: App, name: string): Promise<void> {
|
|||||||
for (const file of PROJECT_FILES) {
|
for (const file of PROJECT_FILES) {
|
||||||
await ensureFile(app, normalizePath(`${root}/${file}`), "");
|
await ensureFile(app, normalizePath(`${root}/${file}`), "");
|
||||||
}
|
}
|
||||||
|
await createCollection(app, name, "story");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createArea(app: App, project: string, area: string): Promise<void> {
|
export async function createCollection(
|
||||||
await ensureFolder(app, areaPath(project, area));
|
app: App,
|
||||||
|
project: string,
|
||||||
|
collection: string,
|
||||||
|
zone: Zone = "to-update",
|
||||||
|
): Promise<void> {
|
||||||
|
if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone));
|
||||||
|
await ensureFolder(app, collectionPath(project, collection, zone));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFeature(
|
export async function createFeature(
|
||||||
app: App,
|
app: App,
|
||||||
project: string,
|
project: string,
|
||||||
area: string,
|
collection: string,
|
||||||
feature: string,
|
feature: string,
|
||||||
|
zone: Zone = "ready",
|
||||||
): Promise<TFile> {
|
): Promise<TFile> {
|
||||||
return await ensureFile(app, featurePath(project, area, feature), "");
|
return await ensureFile(app, featurePath(project, collection, feature, zone), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectFeaturePath(project: string, feature: string): string {
|
export function projectFeaturePath(
|
||||||
|
project: string,
|
||||||
|
feature: string,
|
||||||
|
zone: Zone = "ready",
|
||||||
|
): string {
|
||||||
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
|
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
|
||||||
return normalizePath(`${PROJECTS_ROOT}/${project}/${file}`);
|
return normalizePath(`${zoneRootPath(project, zone)}/${file}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProjectFeature(
|
export async function createProjectFeature(
|
||||||
app: App,
|
app: App,
|
||||||
project: string,
|
project: string,
|
||||||
feature: string,
|
feature: string,
|
||||||
|
zone: Zone = "to-update",
|
||||||
): Promise<TFile> {
|
): Promise<TFile> {
|
||||||
return await ensureFile(app, projectFeaturePath(project, feature), "");
|
if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone));
|
||||||
|
return await ensureFile(app, projectFeaturePath(project, feature, zone), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isProjectRootFile(name: string): boolean {
|
export interface ProjectFileLocation {
|
||||||
return (PROJECT_FILES as readonly string[]).includes(name);
|
project: string;
|
||||||
|
collection?: string;
|
||||||
|
zone: Zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProjectFilePath(path: string): ProjectFileLocation | null {
|
||||||
|
if (!path.endsWith(".md")) return null;
|
||||||
|
const parts = normalizePath(path).split("/");
|
||||||
|
let idx = 0;
|
||||||
|
if (PROJECTS_ROOT) {
|
||||||
|
if (parts[0] !== PROJECTS_ROOT) return null;
|
||||||
|
idx = 1;
|
||||||
|
}
|
||||||
|
if (parts.length < idx + 2) return null;
|
||||||
|
const project = parts[idx];
|
||||||
|
const rest = parts.slice(idx + 1);
|
||||||
|
let zone: Zone = "ready";
|
||||||
|
if (rest[0] === TO_UPDATE_DIR) {
|
||||||
|
zone = "to-update";
|
||||||
|
rest.shift();
|
||||||
|
} else if (rest[0] === IDEAS_DIR) {
|
||||||
|
zone = "ideas";
|
||||||
|
rest.shift();
|
||||||
|
}
|
||||||
|
if (rest.length === 1) return { project, zone };
|
||||||
|
if (rest.length === 2) return { project, collection: rest[0], zone };
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
src/ui.ts
52
src/ui.ts
@@ -1,4 +1,4 @@
|
|||||||
import { App, Menu, Platform, TFile, normalizePath } from "obsidian";
|
import { App, Menu, Platform, TFile, View, WorkspaceLeaf, normalizePath } from "obsidian";
|
||||||
|
|
||||||
export interface CardOptions {
|
export interface CardOptions {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,31 +44,17 @@ export function menu(ev: MouseEvent, items: Array<{ title: string; icon?: string
|
|||||||
m.showAtMouseEvent(ev);
|
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(
|
export async function openMarkdown(
|
||||||
app: App,
|
app: App,
|
||||||
path: string,
|
path: string,
|
||||||
mobileReturn?: MobileReturnTarget,
|
leaf?: WorkspaceLeaf,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const p = normalizePath(path);
|
const p = normalizePath(path);
|
||||||
const f = app.vault.getAbstractFileByPath(p);
|
const f = app.vault.getAbstractFileByPath(p);
|
||||||
if (!(f instanceof TFile)) return;
|
if (!(f instanceof TFile)) return;
|
||||||
if (Platform.isMobile) {
|
if (Platform.isMobile) {
|
||||||
if (mobileReturn) pendingReturn = mobileReturn;
|
const target = leaf ?? app.workspace.getLeaf(false);
|
||||||
const leaf = app.workspace.getLeaf(false);
|
await target.openFile(f);
|
||||||
await leaf.openFile(f);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = app.workspace.getLeavesOfType("markdown");
|
const existing = app.workspace.getLeavesOfType("markdown");
|
||||||
@@ -77,8 +63,8 @@ export async function openMarkdown(
|
|||||||
await target.openFile(f);
|
await target.openFile(f);
|
||||||
app.workspace.revealLeaf(target);
|
app.workspace.revealLeaf(target);
|
||||||
} else {
|
} else {
|
||||||
const leaf = app.workspace.getLeaf("split", "vertical");
|
const target = app.workspace.getLeaf("split", "vertical");
|
||||||
await leaf.openFile(f);
|
await target.openFile(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +73,7 @@ export interface BreadcrumbSegment {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement {
|
function renderBreadcrumbInto(wrap: HTMLElement, segments: BreadcrumbSegment[]): void {
|
||||||
const wrap = parent.createDiv({ cls: "pk-breadcrumb" });
|
|
||||||
segments.forEach((seg, i) => {
|
segments.forEach((seg, i) => {
|
||||||
if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " });
|
if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " });
|
||||||
if (seg.onClick) {
|
if (seg.onClick) {
|
||||||
@@ -98,9 +83,32 @@ export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]):
|
|||||||
wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label });
|
wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement {
|
||||||
|
const wrap = parent.createDiv({ cls: "pk-breadcrumb" });
|
||||||
|
renderBreadcrumbInto(wrap, segments);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function injectMobileBreadcrumb(
|
||||||
|
view: View & { contentEl?: HTMLElement },
|
||||||
|
segments: BreadcrumbSegment[],
|
||||||
|
): void {
|
||||||
|
const root = view.contentEl ?? view.containerEl.querySelector(".view-content");
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll(".pk-mobile-breadcrumb").forEach((el) => el.remove());
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
const target =
|
||||||
|
root.querySelector<HTMLElement>(".markdown-source-view .cm-sizer") ??
|
||||||
|
root.querySelector<HTMLElement>(".markdown-reading-view .markdown-preview-sizer");
|
||||||
|
if (!target) return;
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "pk-breadcrumb pk-mobile-breadcrumb";
|
||||||
|
target.prepend(wrap);
|
||||||
|
renderBreadcrumbInto(wrap, segments);
|
||||||
|
}
|
||||||
|
|
||||||
export function attachEmptyAreaMenu(
|
export function attachEmptyAreaMenu(
|
||||||
el: HTMLElement,
|
el: HTMLElement,
|
||||||
items: () => Array<{ title: string; icon?: string; onClick: () => void }>,
|
items: () => Array<{ title: string; icon?: string; onClick: () => void }>,
|
||||||
|
|||||||
181
src/views/CollectionView.ts
Normal file
181
src/views/CollectionView.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { ItemView, MarkdownRenderer, WorkspaceLeaf, ViewStateResult } from "obsidian";
|
||||||
|
import {
|
||||||
|
VIEW_TYPE_COLLECTION_VIEW,
|
||||||
|
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
|
VIEW_TYPE_PROJECT_VIEW,
|
||||||
|
RIBBON_ICON,
|
||||||
|
} from "../const";
|
||||||
|
import {
|
||||||
|
Zone,
|
||||||
|
collectionPath,
|
||||||
|
featurePath,
|
||||||
|
listMarkdownFiles,
|
||||||
|
readFile,
|
||||||
|
createFeature,
|
||||||
|
deleteRecursive,
|
||||||
|
} from "../fs";
|
||||||
|
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
||||||
|
import { NameModal } from "../modals/NameModal";
|
||||||
|
|
||||||
|
export interface CollectionViewState extends Record<string, unknown> {
|
||||||
|
project: string;
|
||||||
|
collection: string;
|
||||||
|
zone?: Zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 CollectionView extends ItemView {
|
||||||
|
project = "";
|
||||||
|
collection = "";
|
||||||
|
zone: Zone = "ready";
|
||||||
|
private renderToken = 0;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
|
super(leaf);
|
||||||
|
this.navigation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE_COLLECTION_VIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return this.collection ? `Collection: ${this.collection}` : "Collection";
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return RIBBON_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setState(state: CollectionViewState, result: ViewStateResult): Promise<void> {
|
||||||
|
this.project = state?.project ?? "";
|
||||||
|
this.collection = state?.collection ?? (state as { area?: string })?.area ?? "";
|
||||||
|
this.zone = state?.zone ?? "ready";
|
||||||
|
await super.setState(state, result);
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): CollectionViewState {
|
||||||
|
return { project: this.project, collection: this.collection, zone: this.zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 token = ++this.renderToken;
|
||||||
|
const root = this.containerEl.children[1] as HTMLElement;
|
||||||
|
|
||||||
|
if (!this.project || !this.collection) {
|
||||||
|
root.empty();
|
||||||
|
root.addClass("pk-root");
|
||||||
|
emptyState(root, "Keine Collection ausgewählt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = listMarkdownFiles(
|
||||||
|
this.app,
|
||||||
|
collectionPath(this.project, this.collection, this.zone),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const contents = await Promise.all(features.map((f) => readFile(this.app, f.path)));
|
||||||
|
if (token !== this.renderToken) return;
|
||||||
|
|
||||||
|
root.empty();
|
||||||
|
root.addClass("pk-root");
|
||||||
|
|
||||||
|
breadcrumb(root, [
|
||||||
|
{ label: "Projekte", onClick: () => this.openProjectView() },
|
||||||
|
{ label: this.project, onClick: () => this.openProjectDetails() },
|
||||||
|
{ label: this.collection },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
emptyState(root, "Keine Features");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = root.createDiv({ cls: "pk-features-section" });
|
||||||
|
const grid = section.createDiv({ cls: "pk-features-flex" });
|
||||||
|
for (let i = 0; i < features.length; i++) {
|
||||||
|
const f = features[i];
|
||||||
|
const content = contents[i];
|
||||||
|
const btn = grid.createDiv({
|
||||||
|
cls: "pk-btn-card",
|
||||||
|
attr: { role: "button", tabindex: "0" },
|
||||||
|
});
|
||||||
|
btn.createEl("strong", { text: f.basename });
|
||||||
|
const body = btn.createDiv({ cls: "pk-feature-body" });
|
||||||
|
if (content.trim()) {
|
||||||
|
void MarkdownRenderer.render(this.app, content, body, f.path, this);
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => openMarkdown(this.app, f.path, this.leaf));
|
||||||
|
btn.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreate() },
|
||||||
|
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreate(): void {
|
||||||
|
const taken = listMarkdownFiles(
|
||||||
|
this.app,
|
||||||
|
collectionPath(this.project, this.collection, this.zone),
|
||||||
|
[],
|
||||||
|
).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.collection, name, this.zone);
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openDelete(feature: string): Promise<void> {
|
||||||
|
await deleteRecursive(
|
||||||
|
this.app,
|
||||||
|
featurePath(this.project, this.collection, feature, this.zone),
|
||||||
|
);
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openProjectDetails(): Promise<void> {
|
||||||
|
await this.leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
|
active: true,
|
||||||
|
state: { project: this.project },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openProjectView(): Promise<void> {
|
||||||
|
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
|
|
||||||
import {
|
|
||||||
VIEW_TYPE_OVERVIEW,
|
|
||||||
VIEW_TYPE_DETAILS,
|
|
||||||
VIEW_TYPE_PROJECTS,
|
|
||||||
RIBBON_ICON,
|
|
||||||
PROJECT_FILES,
|
|
||||||
CORE_FILE,
|
|
||||||
TARGET_FILE,
|
|
||||||
STORY_FILE,
|
|
||||||
} from "../const";
|
|
||||||
import {
|
|
||||||
projectPath,
|
|
||||||
areaPath,
|
|
||||||
featurePath,
|
|
||||||
projectFeaturePath,
|
|
||||||
listFolders,
|
|
||||||
listMarkdownFiles,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
deleteRecursive,
|
|
||||||
createArea,
|
|
||||||
createFeature,
|
|
||||||
createProjectFeature,
|
|
||||||
} from "../fs";
|
|
||||||
import { 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 = /^[^\\/:*?"<>|]+$/;
|
|
||||||
const FEATURE_DND_MIME = "application/x-pk-feature";
|
|
||||||
|
|
||||||
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_FILE}`);
|
|
||||||
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
|
|
||||||
const storyPath = normalizePath(`${projRoot}/${STORY_FILE}`);
|
|
||||||
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, corePath);
|
|
||||||
this.renderInfoCard(stack, target, targetPath);
|
|
||||||
this.renderStoryCard(stack, story, storyPath);
|
|
||||||
this.renderAreas(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
|
|
||||||
const btn = parent.createDiv({
|
|
||||||
cls: "pk-btn-card",
|
|
||||||
attr: { role: "button", tabindex: "0" },
|
|
||||||
});
|
|
||||||
btn.setText(content || "(leer)");
|
|
||||||
btn.addEventListener("click", () =>
|
|
||||||
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() },
|
|
||||||
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
section.addEventListener("dragover", (ev) => {
|
|
||||||
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.dataTransfer.dropEffect = "move";
|
|
||||||
section.addClass("pk-drop-zone");
|
|
||||||
});
|
|
||||||
section.addEventListener("dragleave", (ev) => {
|
|
||||||
const next = ev.relatedTarget as Node | null;
|
|
||||||
if (next && section.contains(next)) return;
|
|
||||||
section.removeClass("pk-drop-zone");
|
|
||||||
});
|
|
||||||
section.addEventListener("drop", async (ev) => {
|
|
||||||
section.removeClass("pk-drop-zone");
|
|
||||||
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
|
|
||||||
if (!raw) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
await this.handleFeatureDropToProject(raw);
|
|
||||||
});
|
|
||||||
|
|
||||||
const areas = listFolders(this.app, projectPath(this.project));
|
|
||||||
const projectFeatures = listMarkdownFiles(
|
|
||||||
this.app,
|
|
||||||
projectPath(this.project),
|
|
||||||
[...PROJECT_FILES],
|
|
||||||
);
|
|
||||||
if (areas.length === 0 && projectFeatures.length === 0) {
|
|
||||||
emptyState(section, "Noch leer. Rechtsklick → Neue Area / Neues Feature.");
|
|
||||||
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),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const areaCard = stack.createDiv({
|
|
||||||
cls: "pk-btn-card pk-area-card",
|
|
||||||
attr: { role: "button", tabindex: "0" },
|
|
||||||
});
|
|
||||||
areaCard.createEl("strong", { text: area.name });
|
|
||||||
areaCard.addEventListener("click", () => this.openDetails(area.name));
|
|
||||||
areaCard.addEventListener("contextmenu", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
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) },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
areaCard.addEventListener("dragover", (ev) => {
|
|
||||||
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.dataTransfer.dropEffect = "move";
|
|
||||||
areaCard.addClass("pk-drop-target");
|
|
||||||
});
|
|
||||||
areaCard.addEventListener("dragleave", (ev) => {
|
|
||||||
const next = ev.relatedTarget as Node | null;
|
|
||||||
if (next && areaCard.contains(next)) return;
|
|
||||||
areaCard.removeClass("pk-drop-target");
|
|
||||||
});
|
|
||||||
areaCard.addEventListener("drop", async (ev) => {
|
|
||||||
areaCard.removeClass("pk-drop-target");
|
|
||||||
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
|
|
||||||
if (!raw) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
await this.handleFeatureDrop(raw, area.name);
|
|
||||||
});
|
|
||||||
if (features.length === 0) {
|
|
||||||
areaCard.createDiv({ cls: "pk-empty", text: "Keine Features." });
|
|
||||||
} else {
|
|
||||||
const list = areaCard.createDiv({ cls: "pk-features" });
|
|
||||||
for (const f of features) {
|
|
||||||
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
|
|
||||||
chip.draggable = true;
|
|
||||||
chip.addEventListener("dragstart", (ev) => {
|
|
||||||
if (!ev.dataTransfer) return;
|
|
||||||
ev.dataTransfer.setData(
|
|
||||||
FEATURE_DND_MIME,
|
|
||||||
JSON.stringify({ sourceArea: area.name, feature: f.basename }),
|
|
||||||
);
|
|
||||||
ev.dataTransfer.effectAllowed = "move";
|
|
||||||
chip.addClass("pk-feature-chip-dragging");
|
|
||||||
});
|
|
||||||
chip.addEventListener("dragend", () => {
|
|
||||||
chip.removeClass("pk-feature-chip-dragging");
|
|
||||||
});
|
|
||||||
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) },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of projectFeatures) {
|
|
||||||
const fc = stack.createDiv({
|
|
||||||
cls: "pk-btn-card pk-area-card",
|
|
||||||
attr: { role: "button", tabindex: "0", draggable: "true" },
|
|
||||||
});
|
|
||||||
fc.createEl("strong", { text: f.basename });
|
|
||||||
fc.addEventListener("dragstart", (ev) => {
|
|
||||||
if (!ev.dataTransfer) return;
|
|
||||||
ev.dataTransfer.setData(
|
|
||||||
FEATURE_DND_MIME,
|
|
||||||
JSON.stringify({ sourceArea: null, feature: f.basename }),
|
|
||||||
);
|
|
||||||
ev.dataTransfer.effectAllowed = "move";
|
|
||||||
fc.addClass("pk-feature-chip-dragging");
|
|
||||||
});
|
|
||||||
fc.addEventListener("dragend", () => {
|
|
||||||
fc.removeClass("pk-feature-chip-dragging");
|
|
||||||
});
|
|
||||||
fc.addEventListener("click", () =>
|
|
||||||
openMarkdown(this.app, f.path, {
|
|
||||||
type: VIEW_TYPE_OVERVIEW,
|
|
||||||
state: { project: this.project },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
fc.addEventListener("contextmenu", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
menu(ev, [
|
|
||||||
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteProjectFeature(f.basename) },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private openCreateProjectFeature(): void {
|
|
||||||
const taken = listMarkdownFiles(this.app, projectPath(this.project), []).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 createProjectFeature(this.app, this.project, name);
|
|
||||||
await this.render();
|
|
||||||
},
|
|
||||||
}).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
private openDeleteProjectFeature(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, projectFeaturePath(this.project, feature));
|
|
||||||
await this.render();
|
|
||||||
},
|
|
||||||
}).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFeatureDropToProject(raw: string): Promise<void> {
|
|
||||||
let data: { sourceArea: string | null; feature: string };
|
|
||||||
try {
|
|
||||||
data = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!data?.feature) return;
|
|
||||||
if (data.sourceArea === null) return;
|
|
||||||
const newPath = projectFeaturePath(this.project, data.feature);
|
|
||||||
if (this.app.vault.getAbstractFileByPath(newPath)) {
|
|
||||||
new Notice(`„${data.feature}" existiert im Projekt bereits.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const oldPath = featurePath(this.project, data.sourceArea, data.feature);
|
|
||||||
try {
|
|
||||||
await rename(this.app, oldPath, newPath);
|
|
||||||
} catch (e) {
|
|
||||||
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFeatureDrop(raw: string, targetArea: string): Promise<void> {
|
|
||||||
let data: { sourceArea: string | null; feature: string };
|
|
||||||
try {
|
|
||||||
data = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!data?.feature) return;
|
|
||||||
if (data.sourceArea === targetArea) return;
|
|
||||||
const newPath = featurePath(this.project, targetArea, data.feature);
|
|
||||||
if (this.app.vault.getAbstractFileByPath(newPath)) {
|
|
||||||
new Notice(`„${data.feature}" existiert in „${targetArea}" bereits.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const oldPath = data.sourceArea === null
|
|
||||||
? projectFeaturePath(this.project, data.feature)
|
|
||||||
: featurePath(this.project, data.sourceArea, data.feature);
|
|
||||||
try {
|
|
||||||
await rename(this.app, oldPath, newPath);
|
|
||||||
} catch (e) {
|
|
||||||
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
477
src/views/ProjectDetailsView.ts
Normal file
477
src/views/ProjectDetailsView.ts
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
import {
|
||||||
|
ItemView,
|
||||||
|
MarkdownRenderer,
|
||||||
|
Notice,
|
||||||
|
TFile,
|
||||||
|
TFolder,
|
||||||
|
WorkspaceLeaf,
|
||||||
|
ViewStateResult,
|
||||||
|
normalizePath,
|
||||||
|
} from "obsidian";
|
||||||
|
import {
|
||||||
|
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
|
VIEW_TYPE_COLLECTION_VIEW,
|
||||||
|
VIEW_TYPE_PROJECT_VIEW,
|
||||||
|
RIBBON_ICON,
|
||||||
|
PROJECT_FILES,
|
||||||
|
CORE_FILE,
|
||||||
|
DESCRIPTION_FILE,
|
||||||
|
} from "../const";
|
||||||
|
import {
|
||||||
|
Zone,
|
||||||
|
projectPath,
|
||||||
|
zoneRootPath,
|
||||||
|
listFolders,
|
||||||
|
listMarkdownFiles,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
deleteRecursive,
|
||||||
|
ensureFolder,
|
||||||
|
createCollection,
|
||||||
|
createProjectFeature,
|
||||||
|
} from "../fs";
|
||||||
|
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
||||||
|
import { NameModal } from "../modals/NameModal";
|
||||||
|
|
||||||
|
export interface ProjectDetailsState extends Record<string, unknown> {
|
||||||
|
project: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||||
|
const PK_DND_MIME = "application/x-pk-item";
|
||||||
|
|
||||||
|
interface DndPayload {
|
||||||
|
kind: "feature" | "collection";
|
||||||
|
sourcePath: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateName(name: string, taken: string[], current?: string): string | null {
|
||||||
|
if (!name) return "Name darf nicht leer sein.";
|
||||||
|
if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen.";
|
||||||
|
if (taken.includes(name) && name !== current) return "Name existiert bereits.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectDetailsView extends ItemView {
|
||||||
|
project = "";
|
||||||
|
private renderToken = 0;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
|
super(leaf);
|
||||||
|
this.navigation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE_PROJECT_DETAILS_VIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return this.project ? `Projekt: ${this.project}` : "Projekt";
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return RIBBON_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
|
||||||
|
this.project = state?.project ?? "";
|
||||||
|
await super.setState(state, result);
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): ProjectDetailsState {
|
||||||
|
return { project: this.project };
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
await this.render();
|
||||||
|
this.registerEvent(this.app.vault.on("create", () => this.render()));
|
||||||
|
this.registerEvent(this.app.vault.on("delete", () => this.render()));
|
||||||
|
this.registerEvent(this.app.vault.on("rename", () => this.render()));
|
||||||
|
this.registerEvent(this.app.vault.on("modify", (f) => {
|
||||||
|
if (f.path.startsWith(projectPath(this.project) + "/")) this.render();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClose(): Promise<void> {}
|
||||||
|
|
||||||
|
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_FILE}`);
|
||||||
|
const descriptionPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`);
|
||||||
|
const [core, description] = await Promise.all([
|
||||||
|
readFile(this.app, corePath),
|
||||||
|
readFile(this.app, descriptionPath),
|
||||||
|
]);
|
||||||
|
if (token !== this.renderToken) return;
|
||||||
|
|
||||||
|
root.empty();
|
||||||
|
root.addClass("pk-root");
|
||||||
|
|
||||||
|
breadcrumb(root, [
|
||||||
|
{ label: "Projekte", onClick: () => this.openProjectView() },
|
||||||
|
{ label: this.project },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const info = root.createDiv({ cls: "pk-info-grid" });
|
||||||
|
this.renderInfoCard(info, core, corePath);
|
||||||
|
this.renderInfoCard(info, description, descriptionPath);
|
||||||
|
this.renderCollections(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
|
||||||
|
const btn = parent.createDiv({
|
||||||
|
cls: "pk-btn-card pk-info-card",
|
||||||
|
attr: { role: "button", tabindex: "0" },
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf));
|
||||||
|
if (content.trim()) {
|
||||||
|
void MarkdownRenderer.render(this.app, content, btn, path, this);
|
||||||
|
} else {
|
||||||
|
btn.setText("(leer)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCollections(parent: HTMLElement): void {
|
||||||
|
const section = parent.createDiv({ cls: "pk-areas-section" });
|
||||||
|
|
||||||
|
const ready = this.collectZoneItems("ready");
|
||||||
|
const toUpdate = this.collectZoneItems("to-update");
|
||||||
|
const ideas = this.collectZoneItems("ideas");
|
||||||
|
|
||||||
|
this.renderZone(section, "ready", ready);
|
||||||
|
section.createEl("hr", { cls: "pk-zone-divider" });
|
||||||
|
this.renderZone(section, "to-update", toUpdate);
|
||||||
|
section.createEl("hr", { cls: "pk-zone-divider" });
|
||||||
|
this.renderZone(section, "ideas", ideas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectZoneItems(zone: Zone): { collections: TFolder[]; features: TFile[] } {
|
||||||
|
const root = zoneRootPath(this.project, zone);
|
||||||
|
const folders = listFolders(this.app, root);
|
||||||
|
const features = listMarkdownFiles(
|
||||||
|
this.app,
|
||||||
|
root,
|
||||||
|
zone === "ready" ? [...PROJECT_FILES] : [],
|
||||||
|
);
|
||||||
|
return { collections: folders, features };
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneEmptyText(zone: Zone): string {
|
||||||
|
if (zone === "ready") return "Keine fertigen Features";
|
||||||
|
if (zone === "to-update") return "Keine unfertigen Features";
|
||||||
|
return "Keine neuen Features";
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderZone(
|
||||||
|
parent: HTMLElement,
|
||||||
|
zone: Zone,
|
||||||
|
items: { collections: TFolder[]; features: TFile[] },
|
||||||
|
): void {
|
||||||
|
const flex = parent.createDiv({
|
||||||
|
cls: `pk-areas-flex pk-zone-${zone}`,
|
||||||
|
attr: { "data-zone": zone },
|
||||||
|
});
|
||||||
|
flex.addEventListener("contextmenu", (ev) => {
|
||||||
|
if (ev.defaultPrevented) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) },
|
||||||
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
flex.addEventListener("dragover", (ev) => {
|
||||||
|
if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
flex.addClass("pk-drop-zone");
|
||||||
|
});
|
||||||
|
flex.addEventListener("dragleave", (ev) => {
|
||||||
|
const next = ev.relatedTarget as Node | null;
|
||||||
|
if (next && flex.contains(next)) return;
|
||||||
|
flex.removeClass("pk-drop-zone");
|
||||||
|
});
|
||||||
|
flex.addEventListener("drop", async (ev) => {
|
||||||
|
flex.removeClass("pk-drop-zone");
|
||||||
|
const raw = ev.dataTransfer?.getData(PK_DND_MIME);
|
||||||
|
if (!raw) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
await this.handleDropOnZone(raw, zone);
|
||||||
|
});
|
||||||
|
|
||||||
|
const takenCollections = items.collections.map((a) => a.name);
|
||||||
|
for (const collection of items.collections) {
|
||||||
|
this.renderCollectionCard(flex, zone, collection, takenCollections);
|
||||||
|
}
|
||||||
|
for (const f of items.features) {
|
||||||
|
this.renderProjectFeatureCard(flex, zone, f);
|
||||||
|
}
|
||||||
|
if (items.collections.length === 0 && items.features.length === 0) {
|
||||||
|
flex.createDiv({ cls: "pk-zone-placeholder", text: this.zoneEmptyText(zone) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCollectionCard(
|
||||||
|
parent: HTMLElement,
|
||||||
|
zone: Zone,
|
||||||
|
collection: TFolder,
|
||||||
|
takenCollections: string[],
|
||||||
|
): void {
|
||||||
|
const folderPath = collection.path;
|
||||||
|
const features = listMarkdownFiles(this.app, folderPath, []);
|
||||||
|
|
||||||
|
const card = parent.createDiv({
|
||||||
|
cls: "pk-btn-card pk-area-card",
|
||||||
|
attr: { role: "button", tabindex: "0", draggable: "true" },
|
||||||
|
});
|
||||||
|
card.createEl("strong", { text: collection.name });
|
||||||
|
card.addEventListener("click", () => this.openCollectionDetails(collection.name, zone));
|
||||||
|
card.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) },
|
||||||
|
{ title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameCollectionAt(folderPath, collection.name, takenCollections) },
|
||||||
|
{ title: "Collection löschen", icon: "trash", onClick: () => this.openDeletePath(folderPath) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
card.addEventListener("dragstart", (ev) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({
|
||||||
|
kind: "collection",
|
||||||
|
sourcePath: folderPath,
|
||||||
|
name: collection.name,
|
||||||
|
} satisfies DndPayload));
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
card.addClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
|
card.addEventListener("dragend", () => card.removeClass("pk-feature-chip-dragging"));
|
||||||
|
card.addEventListener("dragover", (ev) => {
|
||||||
|
if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
card.addClass("pk-drop-target");
|
||||||
|
});
|
||||||
|
card.addEventListener("dragleave", (ev) => {
|
||||||
|
const next = ev.relatedTarget as Node | null;
|
||||||
|
if (next && card.contains(next)) return;
|
||||||
|
card.removeClass("pk-drop-target");
|
||||||
|
});
|
||||||
|
card.addEventListener("drop", async (ev) => {
|
||||||
|
card.removeClass("pk-drop-target");
|
||||||
|
const raw = ev.dataTransfer?.getData(PK_DND_MIME);
|
||||||
|
if (!raw) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
await this.handleDropOnCollection(raw, folderPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
card.createDiv({ cls: "pk-empty", text: "Keine Features" });
|
||||||
|
} else {
|
||||||
|
const list = card.createDiv({ cls: "pk-features" });
|
||||||
|
for (const f of features) {
|
||||||
|
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
|
||||||
|
chip.draggable = true;
|
||||||
|
chip.addEventListener("dragstart", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({
|
||||||
|
kind: "feature",
|
||||||
|
sourcePath: f.path,
|
||||||
|
name: f.basename,
|
||||||
|
} satisfies DndPayload));
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
chip.addClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
|
chip.addEventListener("dragend", () => chip.removeClass("pk-feature-chip-dragging"));
|
||||||
|
chip.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
openMarkdown(this.app, f.path, this.leaf);
|
||||||
|
});
|
||||||
|
chip.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeatureIn(folderPath) },
|
||||||
|
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(f.path) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderProjectFeatureCard(parent: HTMLElement, zone: Zone, file: TFile): void {
|
||||||
|
const card = parent.createDiv({
|
||||||
|
cls: "pk-btn-card pk-area-card",
|
||||||
|
attr: { role: "button", tabindex: "0", draggable: "true" },
|
||||||
|
});
|
||||||
|
card.createEl("strong", { text: file.basename });
|
||||||
|
card.addEventListener("dragstart", (ev) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({
|
||||||
|
kind: "feature",
|
||||||
|
sourcePath: file.path,
|
||||||
|
name: file.basename,
|
||||||
|
} satisfies DndPayload));
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
card.addClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
|
card.addEventListener("dragend", () => card.removeClass("pk-feature-chip-dragging"));
|
||||||
|
card.addEventListener("click", () => openMarkdown(this.app, file.path, this.leaf));
|
||||||
|
card.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) },
|
||||||
|
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDnd(raw: string): DndPayload | null {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (!data?.sourcePath || !data?.name) return null;
|
||||||
|
if (data.kind !== "feature" && data.kind !== "collection") return null;
|
||||||
|
return data as DndPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDropOnZone(raw: string, zone: Zone): Promise<void> {
|
||||||
|
const data = this.parseDnd(raw);
|
||||||
|
if (!data) return;
|
||||||
|
const root = zoneRootPath(this.project, zone);
|
||||||
|
const targetName = data.kind === "feature"
|
||||||
|
? (data.name.endsWith(".md") ? data.name : `${data.name}.md`)
|
||||||
|
: data.name;
|
||||||
|
const newPath = normalizePath(`${root}/${targetName}`);
|
||||||
|
if (data.sourcePath === newPath) return;
|
||||||
|
if (zone !== "ready") await ensureFolder(this.app, root);
|
||||||
|
await this.movePath(data.sourcePath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDropOnCollection(raw: string, collectionFolderPath: string): Promise<void> {
|
||||||
|
const data = this.parseDnd(raw);
|
||||||
|
if (!data) return;
|
||||||
|
if (data.kind !== "feature") return;
|
||||||
|
const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`;
|
||||||
|
const newPath = normalizePath(`${collectionFolderPath}/${file}`);
|
||||||
|
if (data.sourcePath === newPath) return;
|
||||||
|
await this.movePath(data.sourcePath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async movePath(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
if (this.app.vault.getAbstractFileByPath(newPath)) {
|
||||||
|
new Notice(`„${newPath}" existiert bereits.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rename(this.app, oldPath, newPath);
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateProjectFeature(zone: Zone = "to-update"): void {
|
||||||
|
const root = zoneRootPath(this.project, zone);
|
||||||
|
const existing = listMarkdownFiles(
|
||||||
|
this.app,
|
||||||
|
root,
|
||||||
|
zone === "ready" ? [...PROJECT_FILES] : [],
|
||||||
|
);
|
||||||
|
const taken = existing.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 createProjectFeature(this.app, this.project, name, zone);
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateCollection(zone: Zone = "to-update"): void {
|
||||||
|
const allZones: Zone[] = ["ready", "to-update", "ideas"];
|
||||||
|
const taken = allZones.flatMap((z) =>
|
||||||
|
listFolders(this.app, zoneRootPath(this.project, z)).map((a) => a.name),
|
||||||
|
);
|
||||||
|
new NameModal(this.app, {
|
||||||
|
title: "Neue Collection",
|
||||||
|
label: "Collection-Name",
|
||||||
|
cta: "Erstellen",
|
||||||
|
validate: (n) => validateName(n, taken),
|
||||||
|
onSubmit: async (name) => {
|
||||||
|
await createCollection(this.app, this.project, name, zone);
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateFeatureIn(folderPath: string): void {
|
||||||
|
const taken = listMarkdownFiles(this.app, folderPath, []).map((f) => f.basename);
|
||||||
|
new NameModal(this.app, {
|
||||||
|
title: "Neues Feature",
|
||||||
|
label: "Feature-Name",
|
||||||
|
cta: "Erstellen",
|
||||||
|
validate: (n) => validateName(n, taken),
|
||||||
|
onSubmit: async (name) => {
|
||||||
|
const file = name.endsWith(".md") ? name : `${name}.md`;
|
||||||
|
const path = normalizePath(`${folderPath}/${file}`);
|
||||||
|
const existing = this.app.vault.getAbstractFileByPath(path);
|
||||||
|
if (!existing) await this.app.vault.create(path, "");
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openRenameCollectionAt(folderPath: string, current: string, taken: string[]): void {
|
||||||
|
new NameModal(this.app, {
|
||||||
|
title: "Collection umbenennen",
|
||||||
|
label: "Collection-Name",
|
||||||
|
initial: current,
|
||||||
|
cta: "Speichern",
|
||||||
|
validate: (n) => validateName(n, taken, current),
|
||||||
|
onSubmit: async (name) => {
|
||||||
|
if (name === current) return;
|
||||||
|
const parent = folderPath.substring(0, folderPath.lastIndexOf("/"));
|
||||||
|
await rename(this.app, folderPath, normalizePath(`${parent}/${name}`));
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openDeletePath(path: string): Promise<void> {
|
||||||
|
await deleteRecursive(this.app, path);
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openCollectionDetails(collection: string, zone: Zone): Promise<void> {
|
||||||
|
await this.leaf.setViewState({
|
||||||
|
type: VIEW_TYPE_COLLECTION_VIEW,
|
||||||
|
active: true,
|
||||||
|
state: { project: this.project, collection, zone },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openProjectView(): Promise<void> {
|
||||||
|
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { ItemView, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
|
import {
|
||||||
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON, CORE_FILE } from "../const";
|
ItemView,
|
||||||
|
MarkdownRenderer,
|
||||||
|
Notice,
|
||||||
|
WorkspaceLeaf,
|
||||||
|
normalizePath,
|
||||||
|
} from "obsidian";
|
||||||
|
import {
|
||||||
|
VIEW_TYPE_PROJECT_VIEW,
|
||||||
|
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
|
RIBBON_ICON,
|
||||||
|
CORE_FILE,
|
||||||
|
PROJECTS_ROOT,
|
||||||
|
} from "../const";
|
||||||
import {
|
import {
|
||||||
projectsPath,
|
projectsPath,
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -12,7 +24,6 @@ import {
|
|||||||
} from "../fs";
|
} from "../fs";
|
||||||
import { menu, breadcrumb, emptyState } from "../ui";
|
import { menu, breadcrumb, emptyState } from "../ui";
|
||||||
import { NameModal } from "../modals/NameModal";
|
import { NameModal } from "../modals/NameModal";
|
||||||
import { ConfirmModal } from "../modals/ConfirmModal";
|
|
||||||
|
|
||||||
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||||
|
|
||||||
@@ -23,13 +34,16 @@ function validateName(name: string, taken: string[], current?: string): string |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectsView extends ItemView {
|
export class ProjectView extends ItemView {
|
||||||
|
private renderToken = 0;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf) {
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
|
this.navigation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewType(): string {
|
getViewType(): string {
|
||||||
return VIEW_TYPE_PROJECTS;
|
return VIEW_TYPE_PROJECT_VIEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDisplayText(): string {
|
getDisplayText(): string {
|
||||||
@@ -47,9 +61,9 @@ export class ProjectsView extends ItemView {
|
|||||||
this.registerEvent(this.app.vault.on("delete", () => 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("rename", () => this.render()));
|
||||||
this.registerEvent(this.app.vault.on("modify", (f) => {
|
this.registerEvent(this.app.vault.on("modify", (f) => {
|
||||||
if (f.path.endsWith("/" + CORE_FILE) && f.path.startsWith(projectsPath() + "/")) {
|
if (!f.path.endsWith("/" + CORE_FILE)) return;
|
||||||
|
if (PROJECTS_ROOT && !f.path.startsWith(projectsPath() + "/")) return;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
|
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
|
||||||
if (ev.defaultPrevented) return;
|
if (ev.defaultPrevented) return;
|
||||||
@@ -63,36 +77,43 @@ export class ProjectsView extends ItemView {
|
|||||||
async onClose(): Promise<void> {}
|
async onClose(): Promise<void> {}
|
||||||
|
|
||||||
private async render(): Promise<void> {
|
private async render(): Promise<void> {
|
||||||
|
const token = ++this.renderToken;
|
||||||
const root = this.containerEl.children[1] as HTMLElement;
|
const root = this.containerEl.children[1] as HTMLElement;
|
||||||
root.empty();
|
|
||||||
root.addClass("pk-root");
|
|
||||||
|
|
||||||
breadcrumb(root, [{ label: "Projekte" }]);
|
|
||||||
|
|
||||||
const projects = listFolders(this.app, projectsPath());
|
const projects = listFolders(this.app, projectsPath());
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
|
if (token !== this.renderToken) return;
|
||||||
|
root.empty();
|
||||||
|
root.addClass("pk-root");
|
||||||
|
breadcrumb(root, [{ label: "Projekte" }]);
|
||||||
emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt.");
|
emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const taken = projects.map((p) => p.name);
|
const taken = projects.map((p) => p.name);
|
||||||
const cores = await Promise.all(
|
const corePaths = projects.map((p) => normalizePath(`${projectPath(p.name)}/${CORE_FILE}`));
|
||||||
projects.map((p) =>
|
const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p)));
|
||||||
readFile(this.app, normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)),
|
if (token !== this.renderToken) return;
|
||||||
),
|
|
||||||
);
|
root.empty();
|
||||||
|
root.addClass("pk-root");
|
||||||
|
breadcrumb(root, [{ label: "Projekte" }]);
|
||||||
|
|
||||||
const grid = root.createDiv({ cls: "pk-grid" });
|
const grid = root.createDiv({ cls: "pk-grid" });
|
||||||
for (let i = 0; i < projects.length; i++) {
|
for (let i = 0; i < projects.length; i++) {
|
||||||
const proj = projects[i];
|
const proj = projects[i];
|
||||||
const core = cores[i].trim();
|
const core = cores[i].trim();
|
||||||
|
const corePath = corePaths[i];
|
||||||
const btn = grid.createDiv({
|
const btn = grid.createDiv({
|
||||||
cls: "pk-btn-card",
|
cls: "pk-btn-card",
|
||||||
attr: { role: "button", tabindex: "0" },
|
attr: { role: "button", tabindex: "0" },
|
||||||
});
|
});
|
||||||
btn.createEl("strong", { text: proj.name });
|
btn.createEl("strong", { text: proj.name });
|
||||||
if (core) btn.createDiv({ text: core });
|
if (core) {
|
||||||
btn.addEventListener("click", () => this.openOverview(proj.name));
|
const body = btn.createDiv({ cls: "pk-project-core" });
|
||||||
|
void MarkdownRenderer.render(this.app, core, body, corePath, this);
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => this.openProjectDetails(proj.name));
|
||||||
btn.addEventListener("contextmenu", (ev) => {
|
btn.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
menu(ev, [
|
menu(ev, [
|
||||||
@@ -141,26 +162,18 @@ export class ProjectsView extends ItemView {
|
|||||||
}).open();
|
}).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openDelete(name: string): void {
|
private async openDelete(name: string): Promise<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 {
|
try {
|
||||||
await deleteRecursive(this.app, projectPath(name));
|
await deleteRecursive(this.app, projectPath(name));
|
||||||
await this.render();
|
await this.render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
new Notice(`Fehler: ${(e as Error).message}`);
|
new Notice(`Fehler: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}).open();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openOverview(name: string): Promise<void> {
|
private async openProjectDetails(name: string): Promise<void> {
|
||||||
await this.leaf.setViewState({
|
await this.leaf.setViewState({
|
||||||
type: VIEW_TYPE_OVERVIEW,
|
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||||
active: true,
|
active: true,
|
||||||
state: { project: name },
|
state: { project: name },
|
||||||
});
|
});
|
||||||
100
styles.css
100
styles.css
@@ -3,6 +3,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-toolbar {
|
.pk-toolbar {
|
||||||
@@ -23,10 +24,32 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-stack {
|
.pk-features-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-features-flex {
|
||||||
|
column-width: 300px;
|
||||||
|
column-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-features-flex > .pk-btn-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 500px) {
|
||||||
|
.pk-info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-card {
|
.pk-card {
|
||||||
@@ -136,6 +159,18 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pk-mobile-breadcrumb {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
background: var(--background-primary);
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.pk-btn-card {
|
.pk-btn-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -157,6 +192,43 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pk-project-core {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pk-project-core > p:first-child { margin-top: 0; }
|
||||||
|
.pk-project-core > p:last-child { margin-bottom: 0; }
|
||||||
|
.pk-project-core > ul:first-child,
|
||||||
|
.pk-project-core > ol:first-child { margin-top: 0; }
|
||||||
|
.pk-project-core > ul:last-child,
|
||||||
|
.pk-project-core > ol:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.pk-info-card > p:first-child { margin-top: 0; }
|
||||||
|
.pk-info-card > p:last-child { margin-bottom: 0; }
|
||||||
|
.pk-info-card > ul:first-child,
|
||||||
|
.pk-info-card > ol:first-child { margin-top: 0; }
|
||||||
|
.pk-info-card > ul:last-child,
|
||||||
|
.pk-info-card > ol:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.pk-feature-body > p:first-child { margin-top: 0; }
|
||||||
|
.pk-feature-body > p:last-child { margin-bottom: 0; }
|
||||||
|
.pk-feature-body > ul:first-child,
|
||||||
|
.pk-feature-body > ol:first-child { margin-top: 0; }
|
||||||
|
.pk-feature-body > ul:last-child,
|
||||||
|
.pk-feature-body > ol:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.pk-project-core p,
|
||||||
|
.pk-project-core ul,
|
||||||
|
.pk-project-core ol,
|
||||||
|
.pk-info-card p,
|
||||||
|
.pk-info-card ul,
|
||||||
|
.pk-info-card ol,
|
||||||
|
.pk-feature-body p,
|
||||||
|
.pk-feature-body ul,
|
||||||
|
.pk-feature-body ol {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.pk-drop-target {
|
.pk-drop-target {
|
||||||
outline: 2px dashed var(--text-accent);
|
outline: 2px dashed var(--text-accent);
|
||||||
@@ -175,16 +247,30 @@
|
|||||||
.pk-areas-section {
|
.pk-areas-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
padding: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-areas-flex {
|
.pk-areas-flex {
|
||||||
column-width: 250px;
|
column-width: 200px;
|
||||||
column-gap: 10px;
|
column-gap: 5px;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-zone-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
column-span: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-area-card {
|
.pk-area-card {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-zone-divider {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user