Compare commits

..

8 Commits

Author SHA1 Message Date
Team3
c4f580b55a update 2026-05-21 23:22:24 +02:00
team3
e35ad61b2d update 2026-05-20 21:37:48 +02:00
b98a998689 update 2026-05-05 09:24:35 +02:00
ed26305193 Update 2026-05-04 17:56:00 +02:00
27f7d14d4f update 2026-05-03 13:04:46 +02:00
dddbdeb616 update 2026-05-01 18:37:24 +02:00
Marek
6ea56fb5e0 update 2026-05-01 03:33:43 +02:00
Marek
a723f1ea0f update 2026-04-30 21:39:58 +02:00
25 changed files with 1069 additions and 797 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

136
main.ts
View File

@@ -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;
active: true, if (!file) {
state: (state as Record<string, unknown>) ?? {}, injectMobileBreadcrumb(view, []);
}); return;
this.app.workspace.revealLeaf(leaf); }
const loc = parseProjectFilePath(file.path);
if (!loc) {
injectMobileBreadcrumb(view, []);
return;
}
const segments: BreadcrumbSegment[] = [
{
label: "Projekte",
onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }),
},
{
label: loc.project,
onClick: () =>
void leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { project: loc.project },
}),
},
];
if (loc.collection) {
segments.push({
label: loc.collection,
onClick: () =>
void leaf.setViewState({
type: VIEW_TYPE_COLLECTION_VIEW,
active: true,
state: { project: loc.project, collection: loc.collection, zone: loc.zone },
}),
});
}
segments.push({ label: file.basename });
injectMobileBreadcrumb(view, segments);
} }
} }

View File

@@ -1,9 +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_FILES = ["core.md", "target.md", "story.md"] as const; export const CORE_FILE = "_core.md";
export const DESCRIPTION_FILE = "_description.md";
export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const;

105
src/fs.ts
View File

@@ -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,21 +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 isProjectRootFile(name: string): boolean { export function projectFeaturePath(
return (PROJECT_FILES as readonly string[]).includes(name); project: string,
feature: string,
zone: Zone = "ready",
): string {
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
return normalizePath(`${zoneRootPath(project, zone)}/${file}`);
}
export async function createProjectFeature(
app: App,
project: string,
feature: string,
zone: Zone = "to-update",
): Promise<TFile> {
if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone));
return await ensureFile(app, projectFeaturePath(project, feature, zone), "");
}
export interface ProjectFileLocation {
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;
} }

View File

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

View File

@@ -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
View 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 });
}
}

View File

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

View File

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

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

View File

@@ -1,5 +1,17 @@
import { ItemView, Notice, WorkspaceLeaf } from "obsidian"; import {
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } 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,
@@ -8,10 +20,10 @@ import {
createProject, createProject,
rename, rename,
deleteRecursive, deleteRecursive,
readFile,
} from "../fs"; } from "../fs";
import { card, 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 = /^[^\\/:*?"<>|]+$/;
@@ -22,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 {
@@ -45,6 +60,11 @@ export class ProjectsView extends ItemView {
this.registerEvent(this.app.vault.on("create", () => 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("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) => {
if (!f.path.endsWith("/" + CORE_FILE)) return;
if (PROJECTS_ROOT && !f.path.startsWith(projectsPath() + "/")) return;
this.render();
}));
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => { this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented) return;
ev.preventDefault(); ev.preventDefault();
@@ -57,29 +77,49 @@ 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 corePaths = projects.map((p) => normalizePath(`${projectPath(p.name)}/${CORE_FILE}`));
const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p)));
if (token !== this.renderToken) return;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [{ label: "Projekte" }]);
const grid = root.createDiv({ cls: "pk-grid" }); const grid = root.createDiv({ cls: "pk-grid" });
for (const proj of projects) { for (let i = 0; i < projects.length; i++) {
card(grid, { const proj = projects[i];
title: proj.name, const core = cores[i].trim();
onClick: () => this.openOverview(proj.name), const corePath = corePaths[i];
onContextMenu: (ev) => const btn = grid.createDiv({
menu(ev, [ cls: "pk-btn-card",
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) }, attr: { role: "button", tabindex: "0" },
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) }, });
]), btn.createEl("strong", { text: proj.name });
if (core) {
const body = btn.createDiv({ cls: "pk-project-core" });
void MarkdownRenderer.render(this.app, core, body, corePath, this);
}
btn.addEventListener("click", () => this.openProjectDetails(proj.name));
btn.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
menu(ev, [
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
]);
}); });
} }
} }
@@ -122,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, { try {
title: "Projekt löschen", await deleteRecursive(this.app, projectPath(name));
message: `Projekt „${name}" wird mitsamt aller Areas und Features rekursiv gelöscht. Fortfahren?`, await this.render();
cta: "Löschen", } catch (e) {
destructive: true, new Notice(`Fehler: ${(e as Error).message}`);
onConfirm: async () => { }
try {
await deleteRecursive(this.app, projectPath(name));
await this.render();
} catch (e) {
new Notice(`Fehler: ${(e as Error).message}`);
}
},
}).open();
} }
private async openOverview(name: string): Promise<void> { 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 },
}); });

View File

@@ -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,18 +159,118 @@
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 {
box-sizing: border-box;
padding: 10px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
cursor: pointer;
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
gap: 6px;
}
.pk-btn-card:hover {
background: var(--background-modifier-hover);
}
.pk-btn-card strong {
font-weight: 600;
}
.pk-project-core {
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 {
outline: 2px dashed var(--text-accent);
outline-offset: -2px;
}
.pk-drop-zone {
background: var(--background-modifier-hover);
border-radius: 6px;
}
.pk-feature-chip-dragging {
opacity: 0.4;
}
.pk-areas-section { .pk-areas-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 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;
} }