This commit is contained in:
Team3
2026-05-19 22:02:32 +02:00
parent b98a998689
commit 2e8bf9599e
7 changed files with 317 additions and 259 deletions

20
main.ts
View File

@@ -8,7 +8,7 @@ import {
import { ProjectView } from "./src/views/ProjectView"; import { ProjectView } from "./src/views/ProjectView";
import { ProjectDetailsView } from "./src/views/ProjectDetailsView"; import { ProjectDetailsView } from "./src/views/ProjectDetailsView";
import { CollectionView } from "./src/views/CollectionView"; import { CollectionView } from "./src/views/CollectionView";
import { parseProjectFilePath } from "./src/fs"; import { parseProjectFilePath, projectPathFromChain } from "./src/fs";
import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui"; import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui";
export default class ProjektkontextPlugin extends Plugin { export default class ProjektkontextPlugin extends Plugin {
@@ -63,7 +63,7 @@ export default class ProjektkontextPlugin extends Plugin {
injectMobileBreadcrumb(view, []); injectMobileBreadcrumb(view, []);
return; return;
} }
const loc = parseProjectFilePath(file.path); const loc = parseProjectFilePath(this.app, file.path);
if (!loc) { if (!loc) {
injectMobileBreadcrumb(view, []); injectMobileBreadcrumb(view, []);
return; return;
@@ -73,24 +73,28 @@ export default class ProjektkontextPlugin extends Plugin {
label: "Projekte", label: "Projekte",
onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }), onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }),
}, },
{ ];
label: loc.project, for (let i = 0; i < loc.projectChain.length; i++) {
const projectPath = projectPathFromChain(loc.projectChain.slice(0, i + 1));
segments.push({
label: loc.projectChain[i],
onClick: () => onClick: () =>
void leaf.setViewState({ void leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW, type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true, active: true,
state: { project: loc.project }, state: { projectPath },
}), }),
}, });
]; }
if (loc.collection) { if (loc.collection) {
const projectPath = projectPathFromChain(loc.projectChain);
segments.push({ segments.push({
label: loc.collection, label: loc.collection,
onClick: () => onClick: () =>
void leaf.setViewState({ void leaf.setViewState({
type: VIEW_TYPE_COLLECTION_VIEW, type: VIEW_TYPE_COLLECTION_VIEW,
active: true, active: true,
state: { project: loc.project, collection: loc.collection, zone: loc.zone }, state: { projectPath, collection: loc.collection },
}), }),
}); });
} }

View File

@@ -1,7 +1,3 @@
export const PROJECTS_ROOT = "projects";
export const TO_UPDATE_DIR = "_to-update";
export const IDEAS_DIR = "_ideas";
export const VIEW_TYPE_PROJECT_VIEW = "projektkontext-projects"; export const VIEW_TYPE_PROJECT_VIEW = "projektkontext-projects";
export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "projektkontext-overview"; export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "projektkontext-overview";
export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details"; export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details";
@@ -9,6 +5,6 @@ export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details";
export const RIBBON_ICON = "layout-grid"; export const RIBBON_ICON = "layout-grid";
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 PROJECT_FILES = [CORE_FILE, TARGET_FILE] as const; export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const;

165
src/fs.ts
View File

@@ -1,42 +1,54 @@
import { App, TFile, TFolder, normalizePath } from "obsidian"; import { App, TFile, TFolder, normalizePath } from "obsidian";
import { PROJECTS_ROOT, PROJECT_FILES, TO_UPDATE_DIR, IDEAS_DIR } from "./const"; import { PROJECT_FILES, CORE_FILE, DESCRIPTION_FILE } from "./const";
export type Zone = "ready" | "to-update" | "ideas"; function hasFile(app: App, folderPath: string, fileName: string): boolean {
const p = normalizePath(`${folderPath}/${fileName}`);
export function projectsPath(): string { return app.vault.getAbstractFileByPath(p) instanceof TFile;
return PROJECTS_ROOT;
} }
export function projectPath(name: string): string { export function projectFolder(projectPath: string): string {
return normalizePath(`${PROJECTS_ROOT}/${name}`); return normalizePath(projectPath);
} }
export function toUpdatePath(project: string): string { export function subProjectPath(parent: string, name: string): string {
return normalizePath(`${PROJECTS_ROOT}/${project}/${TO_UPDATE_DIR}`); return normalizePath(`${parent}/${name}`);
} }
export function ideasPath(project: string): string { export function collectionPath(projectPath: string, collection: string): string {
return normalizePath(`${PROJECTS_ROOT}/${project}/${IDEAS_DIR}`); return normalizePath(`${projectPath}/${collection}`);
}
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( export function featurePath(
project: string, projectPath: string,
collection: string, collection: string,
feature: string, feature: string,
zone: Zone = "ready",
): string { ): string {
const file = feature.endsWith(".md") ? feature : `${feature}.md`; const file = feature.endsWith(".md") ? feature : `${feature}.md`;
return normalizePath(`${zoneRootPath(project, zone)}/${collection}/${file}`); return normalizePath(`${projectPath}/${collection}/${file}`);
}
export function projectFeaturePath(projectPath: string, feature: string): string {
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
return normalizePath(`${projectPath}/${file}`);
}
export function hasCore(app: App, folderPath: string): boolean {
return hasFile(app, folderPath, CORE_FILE);
}
export function hasDescription(app: App, folderPath: string): boolean {
return hasFile(app, folderPath, DESCRIPTION_FILE);
}
export function hasMarker(app: App, folderPath: string): boolean {
return hasCore(app, folderPath) || hasDescription(app, folderPath);
}
export function isCollectionFolder(app: App, folderPath: string): boolean {
const parts = normalizePath(folderPath).split("/");
if (parts.length < 2) return false;
const parent = parts.slice(0, -1).join("/");
return hasMarker(app, parent) && !hasMarker(app, folderPath);
} }
export async function ensureFolder(app: App, path: string): Promise<void> { export async function ensureFolder(app: App, path: string): Promise<void> {
@@ -54,16 +66,32 @@ export async function ensureFile(app: App, path: string, content = ""): Promise<
return await app.vault.create(p, content); return await app.vault.create(p, content);
} }
export function listFolders(app: App, path: string): TFolder[] { function visibleFolders(folder: TFolder): TFolder[] {
const p = normalizePath(path);
const folder = app.vault.getAbstractFileByPath(p);
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("__")) .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 listFolders(app: App, path: string): TFolder[] {
const folder = app.vault.getAbstractFileByPath(normalizePath(path));
if (!(folder instanceof TFolder)) return [];
return visibleFolders(folder);
}
export function listTopLevelProjects(app: App): TFolder[] {
const root = app.vault.getRoot();
return visibleFolders(root);
}
export function listSubProjects(app: App, projectPath: string): TFolder[] {
return listFolders(app, projectPath).filter((f) => !isCollectionFolder(app, f.path));
}
export function listCollections(app: App, projectPath: string): TFolder[] {
return listFolders(app, projectPath).filter((f) => isCollectionFolder(app, f.path));
}
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 p = normalizePath(path);
const folder = app.vault.getAbstractFileByPath(p); const folder = app.vault.getAbstractFileByPath(p);
@@ -75,6 +103,10 @@ export function listMarkdownFiles(app: App, path: string, exclude: string[] = []
.sort((a, b) => a.basename.localeCompare(b.basename)); .sort((a, b) => a.basename.localeCompare(b.basename));
} }
export function listProjectFeatures(app: App, projectPath: string): TFile[] {
return listMarkdownFiles(app, projectPath, [...PROJECT_FILES]);
}
export async function readFile(app: App, path: string): Promise<string> { export async function readFile(app: App, path: string): Promise<string> {
const p = normalizePath(path); const p = normalizePath(path);
const f = app.vault.getAbstractFileByPath(p); const f = app.vault.getAbstractFileByPath(p);
@@ -95,77 +127,66 @@ export async function rename(app: App, oldPath: string, newPath: string): Promis
await app.fileManager.renameFile(f, normalizePath(newPath)); await app.fileManager.renameFile(f, normalizePath(newPath));
} }
export async function createProject(app: App, name: string): Promise<void> { export async function createProject(app: App, projectPath: string): Promise<void> {
const root = projectPath(name); const root = normalizePath(projectPath);
await ensureFolder(app, projectsPath());
await ensureFolder(app, root); await ensureFolder(app, root);
for (const file of PROJECT_FILES) { await ensureFile(app, normalizePath(`${root}/${CORE_FILE}`), "");
await ensureFile(app, normalizePath(`${root}/${file}`), ""); await ensureFile(app, normalizePath(`${root}/${DESCRIPTION_FILE}`), "");
}
await createCollection(app, name, "story");
} }
export async function createCollection( export async function createCollection(
app: App, app: App,
project: string, projectPath: string,
collection: string, collection: string,
zone: Zone = "to-update",
): Promise<void> { ): Promise<void> {
if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone)); await ensureFolder(app, collectionPath(projectPath, collection));
await ensureFolder(app, collectionPath(project, collection, zone));
} }
export async function createFeature( export async function createFeature(
app: App, app: App,
project: string, projectPath: string,
collection: string, collection: string,
feature: string, feature: string,
zone: Zone = "ready",
): Promise<TFile> { ): Promise<TFile> {
return await ensureFile(app, featurePath(project, collection, feature, zone), ""); return await ensureFile(app, featurePath(projectPath, collection, feature), "");
}
export function projectFeaturePath(
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( export async function createProjectFeature(
app: App, app: App,
project: string, projectPath: string,
feature: string, feature: string,
zone: Zone = "to-update",
): Promise<TFile> { ): Promise<TFile> {
if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone)); return await ensureFile(app, projectFeaturePath(projectPath, feature), "");
return await ensureFile(app, projectFeaturePath(project, feature, zone), "");
} }
export interface ProjectFileLocation { export interface ProjectFileLocation {
project: string; projectChain: string[];
collection?: string; collection?: string;
zone: Zone;
} }
export function parseProjectFilePath(path: string): ProjectFileLocation | null { export function parseProjectFilePath(app: App, path: string): ProjectFileLocation | null {
if (!path.endsWith(".md")) return null; if (!path.endsWith(".md")) return null;
const parts = normalizePath(path).split("/"); const parts = normalizePath(path).split("/");
if (parts[0] !== PROJECTS_ROOT) return null; if (parts.length < 2) return null;
if (parts.length < 3) return null; const segments = parts.slice(0, -1);
const project = parts[1]; if (segments.length === 0) return null;
const rest = parts.slice(2);
let zone: Zone = "ready"; const projectChain: string[] = [segments[0]];
if (rest[0] === TO_UPDATE_DIR) { let collection: string | undefined;
zone = "to-update";
rest.shift(); for (let i = 1; i < segments.length; i++) {
} else if (rest[0] === IDEAS_DIR) { const prefix = segments.slice(0, i + 1).join("/");
zone = "ideas"; if (isCollectionFolder(app, prefix)) {
rest.shift(); if (i !== segments.length - 1) return null;
collection = segments[i];
} else {
projectChain.push(segments[i]);
} }
if (rest.length === 1) return { project, zone }; }
if (rest.length === 2) return { project, collection: rest[0], zone };
return null; return { projectChain, collection };
}
export function projectPathFromChain(chain: string[]): string {
return normalizePath(chain.join("/"));
} }

View File

@@ -6,7 +6,6 @@ import {
RIBBON_ICON, RIBBON_ICON,
} from "../const"; } from "../const";
import { import {
Zone,
collectionPath, collectionPath,
featurePath, featurePath,
listMarkdownFiles, listMarkdownFiles,
@@ -14,13 +13,12 @@ import {
createFeature, createFeature,
deleteRecursive, deleteRecursive,
} from "../fs"; } from "../fs";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui";
import { NameModal } from "../modals/NameModal"; import { NameModal } from "../modals/NameModal";
export interface CollectionViewState extends Record<string, unknown> { export interface CollectionViewState extends Record<string, unknown> {
project: string; projectPath: string;
collection: string; collection: string;
zone?: Zone;
} }
const NAME_RX = /^[^\\/:*?"<>|]+$/; const NAME_RX = /^[^\\/:*?"<>|]+$/;
@@ -33,9 +31,8 @@ function validateName(name: string, taken: string[]): string | null {
} }
export class CollectionView extends ItemView { export class CollectionView extends ItemView {
project = ""; projectPath = "";
collection = ""; collection = "";
zone: Zone = "ready";
private renderToken = 0; private renderToken = 0;
constructor(leaf: WorkspaceLeaf) { constructor(leaf: WorkspaceLeaf) {
@@ -56,15 +53,14 @@ export class CollectionView extends ItemView {
} }
async setState(state: CollectionViewState, result: ViewStateResult): Promise<void> { async setState(state: CollectionViewState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? ""; this.projectPath = state?.projectPath ?? "";
this.collection = state?.collection ?? (state as { area?: string })?.area ?? ""; this.collection = state?.collection ?? "";
this.zone = state?.zone ?? "ready";
await super.setState(state, result); await super.setState(state, result);
await this.render(); await this.render();
} }
getState(): CollectionViewState { getState(): CollectionViewState {
return { project: this.project, collection: this.collection, zone: this.zone }; return { projectPath: this.projectPath, collection: this.collection };
} }
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
@@ -87,7 +83,7 @@ export class CollectionView extends ItemView {
const token = ++this.renderToken; const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement; const root = this.containerEl.children[1] as HTMLElement;
if (!this.project || !this.collection) { if (!this.projectPath || !this.collection) {
root.empty(); root.empty();
root.addClass("pk-root"); root.addClass("pk-root");
emptyState(root, "Keine Collection ausgewählt."); emptyState(root, "Keine Collection ausgewählt.");
@@ -96,7 +92,7 @@ export class CollectionView extends ItemView {
const features = listMarkdownFiles( const features = listMarkdownFiles(
this.app, this.app,
collectionPath(this.project, this.collection, this.zone), collectionPath(this.projectPath, this.collection),
[], [],
); );
const contents = await Promise.all(features.map((f) => readFile(this.app, f.path))); const contents = await Promise.all(features.map((f) => readFile(this.app, f.path)));
@@ -105,11 +101,7 @@ export class CollectionView extends ItemView {
root.empty(); root.empty();
root.addClass("pk-root"); root.addClass("pk-root");
breadcrumb(root, [ this.renderBreadcrumb(root);
{ label: "Projekte", onClick: () => this.openProjectView() },
{ label: this.project, onClick: () => this.openProjectDetails() },
{ label: this.collection },
]);
if (features.length === 0) { if (features.length === 0) {
emptyState(root, "Keine Features"); emptyState(root, "Keine Features");
@@ -141,10 +133,26 @@ export class CollectionView extends ItemView {
} }
} }
private renderBreadcrumb(parent: HTMLElement): void {
const chain = this.projectPath.split("/");
const segments: BreadcrumbSegment[] = [
{ label: "Projekte", onClick: () => this.openProjectView() },
];
for (let i = 0; i < chain.length; i++) {
const path = chain.slice(0, i + 1).join("/");
segments.push({
label: chain[i],
onClick: () => this.openProjectDetails(path),
});
}
segments.push({ label: this.collection });
breadcrumb(parent, segments);
}
private openCreate(): void { private openCreate(): void {
const taken = listMarkdownFiles( const taken = listMarkdownFiles(
this.app, this.app,
collectionPath(this.project, this.collection, this.zone), collectionPath(this.projectPath, this.collection),
[], [],
).map((f) => f.basename); ).map((f) => f.basename);
new NameModal(this.app, { new NameModal(this.app, {
@@ -153,7 +161,7 @@ export class CollectionView extends ItemView {
cta: "Erstellen", cta: "Erstellen",
validate: (n) => validateName(n, taken), validate: (n) => validateName(n, taken),
onSubmit: async (name) => { onSubmit: async (name) => {
await createFeature(this.app, this.project, this.collection, name, this.zone); await createFeature(this.app, this.projectPath, this.collection, name);
await this.render(); await this.render();
}, },
}).open(); }).open();
@@ -162,16 +170,16 @@ export class CollectionView extends ItemView {
private async openDelete(feature: string): Promise<void> { private async openDelete(feature: string): Promise<void> {
await deleteRecursive( await deleteRecursive(
this.app, this.app,
featurePath(this.project, this.collection, feature, this.zone), featurePath(this.projectPath, this.collection, feature),
); );
await this.render(); await this.render();
} }
private async openProjectDetails(): Promise<void> { private async openProjectDetails(projectPath: string): Promise<void> {
await this.leaf.setViewState({ await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW, type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true, active: true,
state: { project: this.project }, state: { projectPath },
}); });
} }

View File

@@ -13,35 +13,36 @@ import {
VIEW_TYPE_COLLECTION_VIEW, VIEW_TYPE_COLLECTION_VIEW,
VIEW_TYPE_PROJECT_VIEW, VIEW_TYPE_PROJECT_VIEW,
RIBBON_ICON, RIBBON_ICON,
PROJECT_FILES,
CORE_FILE, CORE_FILE,
TARGET_FILE, DESCRIPTION_FILE,
} from "../const"; } from "../const";
import { import {
Zone, projectFolder,
projectPath, subProjectPath,
zoneRootPath, collectionPath,
listFolders, listSubProjects,
listCollections,
listProjectFeatures,
listMarkdownFiles, listMarkdownFiles,
readFile, readFile,
rename, rename,
deleteRecursive, deleteRecursive,
ensureFolder,
createCollection, createCollection,
createProject,
createProjectFeature, createProjectFeature,
} from "../fs"; } from "../fs";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui";
import { NameModal } from "../modals/NameModal"; import { NameModal } from "../modals/NameModal";
export interface ProjectDetailsState extends Record<string, unknown> { export interface ProjectDetailsState extends Record<string, unknown> {
project: string; projectPath: string;
} }
const NAME_RX = /^[^\\/:*?"<>|]+$/; const NAME_RX = /^[^\\/:*?"<>|]+$/;
const PK_DND_MIME = "application/x-pk-item"; const PK_DND_MIME = "application/x-pk-item";
interface DndPayload { interface DndPayload {
kind: "feature" | "collection"; kind: "feature";
sourcePath: string; sourcePath: string;
name: string; name: string;
} }
@@ -54,7 +55,7 @@ function validateName(name: string, taken: string[], current?: string): string |
} }
export class ProjectDetailsView extends ItemView { export class ProjectDetailsView extends ItemView {
project = ""; projectPath = "";
private renderToken = 0; private renderToken = 0;
constructor(leaf: WorkspaceLeaf) { constructor(leaf: WorkspaceLeaf) {
@@ -67,7 +68,9 @@ export class ProjectDetailsView extends ItemView {
} }
getDisplayText(): string { getDisplayText(): string {
return this.project ? `Projekt: ${this.project}` : "Projekt"; if (!this.projectPath) return "Projekt";
const segs = this.projectPath.split("/");
return `Projekt: ${segs[segs.length - 1]}`;
} }
getIcon(): string { getIcon(): string {
@@ -75,13 +78,13 @@ export class ProjectDetailsView extends ItemView {
} }
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> { async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? ""; this.projectPath = state?.projectPath ?? "";
await super.setState(state, result); await super.setState(state, result);
await this.render(); await this.render();
} }
getState(): ProjectDetailsState { getState(): ProjectDetailsState {
return { project: this.project }; return { projectPath: this.projectPath };
} }
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
@@ -90,7 +93,7 @@ export class ProjectDetailsView 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.startsWith(projectPath(this.project) + "/")) this.render(); if (this.projectPath && f.path.startsWith(this.projectPath + "/")) this.render();
})); }));
} }
@@ -100,34 +103,55 @@ export class ProjectDetailsView extends ItemView {
const token = ++this.renderToken; const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement; const root = this.containerEl.children[1] as HTMLElement;
if (!this.project) { if (!this.projectPath) {
root.empty(); root.empty();
root.addClass("pk-root"); root.addClass("pk-root");
emptyState(root, "Kein Projekt ausgewählt."); emptyState(root, "Kein Projekt ausgewählt.");
return; return;
} }
const projRoot = projectPath(this.project); const projRoot = projectFolder(this.projectPath);
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`); const descPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`);
const [core, target] = await Promise.all([ const subProjectsList = listSubProjects(this.app, this.projectPath);
const subCorePaths = subProjectsList.map((s) => normalizePath(`${s.path}/${CORE_FILE}`));
const [core, desc, ...subCores] = await Promise.all([
readFile(this.app, corePath), readFile(this.app, corePath),
readFile(this.app, targetPath), readFile(this.app, descPath),
...subCorePaths.map((p) => readFile(this.app, p)),
]); ]);
if (token !== this.renderToken) return; if (token !== this.renderToken) return;
root.empty(); root.empty();
root.addClass("pk-root"); root.addClass("pk-root");
breadcrumb(root, [ this.renderBreadcrumb(root);
{ label: "Projekte", onClick: () => this.openProjectView() },
{ label: this.project },
]);
const infoCards: Array<{ content: string; path: string }> = [];
if (core.trim()) infoCards.push({ content: core, path: corePath });
if (desc.trim()) infoCards.push({ content: desc, path: descPath });
if (infoCards.length > 0) {
const info = root.createDiv({ cls: "pk-info-grid" }); const info = root.createDiv({ cls: "pk-info-grid" });
this.renderInfoCard(info, core, corePath); for (const ic of infoCards) this.renderInfoCard(info, ic.content, ic.path);
this.renderInfoCard(info, target, targetPath); }
this.renderCollections(root);
this.renderChildren(root, subProjectsList, subCorePaths, subCores);
}
private renderBreadcrumb(parent: HTMLElement): void {
const chain = this.projectPath.split("/");
const segments: BreadcrumbSegment[] = [
{ label: "Projekte", onClick: () => this.openProjectView() },
];
for (let i = 0; i < chain.length; i++) {
const path = chain.slice(0, i + 1).join("/");
const isLast = i === chain.length - 1;
segments.push({
label: chain[i],
onClick: isLast ? undefined : () => this.openProjectDetails(path),
});
}
breadcrumb(parent, segments);
} }
private renderInfoCard(parent: HTMLElement, content: string, path: string): void { private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
@@ -136,60 +160,25 @@ export class ProjectDetailsView extends ItemView {
attr: { role: "button", tabindex: "0" }, attr: { role: "button", tabindex: "0" },
}); });
btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf)); btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf));
if (content.trim()) {
void MarkdownRenderer.render(this.app, content, btn, path, this); void MarkdownRenderer.render(this.app, content, btn, path, this);
} else {
btn.setText("(leer)");
}
} }
private renderCollections(parent: HTMLElement): void { private renderChildren(
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, parent: HTMLElement,
zone: Zone, subProjects: TFolder[],
items: { collections: TFolder[]; features: TFile[] }, subCorePaths: string[],
subCores: string[],
): void { ): void {
const flex = parent.createDiv({ const collections = listCollections(this.app, this.projectPath);
cls: `pk-areas-flex pk-zone-${zone}`, const features = listProjectFeatures(this.app, this.projectPath);
attr: { "data-zone": zone },
}); const section = parent.createDiv({ cls: "pk-areas-section" });
const flex = section.createDiv({ cls: "pk-areas-flex" });
flex.addEventListener("contextmenu", (ev) => { flex.addEventListener("contextmenu", (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented) return;
ev.preventDefault(); ev.preventDefault();
menu(ev, [ this.openProjectLevelMenu(ev);
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) },
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) },
]);
}); });
flex.addEventListener("dragover", (ev) => { flex.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return;
@@ -207,56 +196,88 @@ export class ProjectDetailsView extends ItemView {
const raw = ev.dataTransfer?.getData(PK_DND_MIME); const raw = ev.dataTransfer?.getData(PK_DND_MIME);
if (!raw) return; if (!raw) return;
ev.preventDefault(); ev.preventDefault();
await this.handleDropOnZone(raw, zone); await this.handleDropOnProjectRoot(raw);
}); });
const takenCollections = items.collections.map((a) => a.name); const takenChildren = [
for (const collection of items.collections) { ...subProjects.map((f) => f.name),
this.renderCollectionCard(flex, zone, collection, takenCollections); ...collections.map((f) => f.name),
];
for (let i = 0; i < subProjects.length; i++) {
this.renderSubProjectCard(flex, subProjects[i], takenChildren, subCores[i] ?? "", subCorePaths[i] ?? "");
} }
for (const f of items.features) { for (const col of collections) {
this.renderProjectFeatureCard(flex, zone, f); this.renderCollectionCard(flex, col, takenChildren);
} }
if (items.collections.length === 0 && items.features.length === 0) { for (const f of features) {
flex.createDiv({ cls: "pk-zone-placeholder", text: this.zoneEmptyText(zone) }); this.renderProjectFeatureCard(flex, f);
} }
if (subProjects.length === 0 && collections.length === 0 && features.length === 0) {
flex.createDiv({ cls: "pk-zone-placeholder", text: "Keine Inhalte. Rechtsklick fügt Sub-Projekt, Collection oder Feature hinzu." });
}
}
private openProjectLevelMenu(ev: MouseEvent): void {
menu(ev, [
{ title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() },
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() },
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
]);
}
private renderSubProjectCard(
parent: HTMLElement,
sub: TFolder,
takenChildren: string[],
coreContent: string,
corePath: string,
): void {
const card = parent.createDiv({
cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0" },
});
card.createEl("strong", { text: sub.name });
const trimmed = coreContent.trim();
if (trimmed) {
const body = card.createDiv({ cls: "pk-project-core" });
void MarkdownRenderer.render(this.app, coreContent, body, corePath, this);
}
card.addEventListener("click", () => this.openProjectDetails(sub.path));
card.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() },
{ title: "Sub-Projekt umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(sub, takenChildren) },
{ title: "Sub-Projekt löschen", icon: "trash", onClick: () => this.openDeletePath(sub.path) },
]);
});
} }
private renderCollectionCard( private renderCollectionCard(
parent: HTMLElement, parent: HTMLElement,
zone: Zone,
collection: TFolder, collection: TFolder,
takenCollections: string[], takenChildren: string[],
): void { ): void {
const folderPath = collection.path; const folderPath = collection.path;
const features = listMarkdownFiles(this.app, folderPath, []); const features = listMarkdownFiles(this.app, folderPath, []);
const card = parent.createDiv({ const card = parent.createDiv({
cls: "pk-btn-card pk-area-card", cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0", draggable: "true" }, attr: { role: "button", tabindex: "0" },
}); });
card.createEl("strong", { text: collection.name }); card.createEl("strong", { text: collection.name });
card.addEventListener("click", () => this.openCollectionDetails(collection.name, zone)); card.addEventListener("click", () => this.openCollectionDetails(collection.name));
card.addEventListener("contextmenu", (ev) => { card.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
menu(ev, [ menu(ev, [
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) }, { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() },
{ title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameCollectionAt(folderPath, collection.name, takenCollections) }, { title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(collection, takenChildren) },
{ title: "Collection löschen", icon: "trash", onClick: () => this.openDeletePath(folderPath) }, { 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) => { card.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return;
ev.preventDefault(); ev.preventDefault();
@@ -312,7 +333,7 @@ export class ProjectDetailsView extends ItemView {
} }
} }
private renderProjectFeatureCard(parent: HTMLElement, zone: Zone, file: TFile): void { private renderProjectFeatureCard(parent: HTMLElement, file: TFile): void {
const card = parent.createDiv({ const card = parent.createDiv({
cls: "pk-btn-card pk-area-card", cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0", draggable: "true" }, attr: { role: "button", tabindex: "0", draggable: "true" },
@@ -334,7 +355,7 @@ export class ProjectDetailsView extends ItemView {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
menu(ev, [ menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) }, { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) }, { title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) },
]); ]);
}); });
@@ -344,30 +365,25 @@ export class ProjectDetailsView extends ItemView {
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (!data?.sourcePath || !data?.name) return null; if (!data?.sourcePath || !data?.name) return null;
if (data.kind !== "feature" && data.kind !== "collection") return null; if (data.kind !== "feature") return null;
return data as DndPayload; return data as DndPayload;
} catch { } catch {
return null; return null;
} }
} }
private async handleDropOnZone(raw: string, zone: Zone): Promise<void> { private async handleDropOnProjectRoot(raw: string): Promise<void> {
const data = this.parseDnd(raw); const data = this.parseDnd(raw);
if (!data) return; if (!data) return;
const root = zoneRootPath(this.project, zone); const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`;
const targetName = data.kind === "feature" const newPath = normalizePath(`${this.projectPath}/${file}`);
? (data.name.endsWith(".md") ? data.name : `${data.name}.md`)
: data.name;
const newPath = normalizePath(`${root}/${targetName}`);
if (data.sourcePath === newPath) return; if (data.sourcePath === newPath) return;
if (zone !== "ready") await ensureFolder(this.app, root);
await this.movePath(data.sourcePath, newPath); await this.movePath(data.sourcePath, newPath);
} }
private async handleDropOnCollection(raw: string, collectionFolderPath: string): Promise<void> { private async handleDropOnCollection(raw: string, collectionFolderPath: string): Promise<void> {
const data = this.parseDnd(raw); const data = this.parseDnd(raw);
if (!data) return; if (!data) return;
if (data.kind !== "feature") return;
const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`;
const newPath = normalizePath(`${collectionFolderPath}/${file}`); const newPath = normalizePath(`${collectionFolderPath}/${file}`);
if (data.sourcePath === newPath) return; if (data.sourcePath === newPath) return;
@@ -388,38 +404,49 @@ export class ProjectDetailsView extends ItemView {
await this.render(); await this.render();
} }
private openCreateProjectFeature(zone: Zone = "to-update"): void { private openCreateProjectFeature(): void {
const root = zoneRootPath(this.project, zone); const taken = listProjectFeatures(this.app, this.projectPath).map((f) => f.basename);
const existing = listMarkdownFiles(
this.app,
root,
zone === "ready" ? [...PROJECT_FILES] : [],
);
const taken = existing.map((f) => f.basename);
new NameModal(this.app, { new NameModal(this.app, {
title: "Neues Feature", title: "Neues Feature",
label: "Feature-Name", label: "Feature-Name",
cta: "Erstellen", cta: "Erstellen",
validate: (n) => validateName(n, taken), validate: (n) => validateName(n, taken),
onSubmit: async (name) => { onSubmit: async (name) => {
await createProjectFeature(this.app, this.project, name, zone); await createProjectFeature(this.app, this.projectPath, name);
await this.render(); await this.render();
}, },
}).open(); }).open();
} }
private openCreateCollection(zone: Zone = "to-update"): void { private openCreateCollection(): void {
const allZones: Zone[] = ["ready", "to-update", "ideas"]; const taken = [
const taken = allZones.flatMap((z) => ...listCollections(this.app, this.projectPath).map((f) => f.name),
listFolders(this.app, zoneRootPath(this.project, z)).map((a) => a.name), ...listSubProjects(this.app, this.projectPath).map((f) => f.name),
); ];
new NameModal(this.app, { new NameModal(this.app, {
title: "Neue Collection", title: "Neue Collection",
label: "Collection-Name", label: "Collection-Name",
cta: "Erstellen", cta: "Erstellen",
validate: (n) => validateName(n, taken), validate: (n) => validateName(n, taken),
onSubmit: async (name) => { onSubmit: async (name) => {
await createCollection(this.app, this.project, name, zone); await createCollection(this.app, this.projectPath, name);
await this.render();
},
}).open();
}
private openCreateSubProject(): void {
const taken = [
...listSubProjects(this.app, this.projectPath).map((f) => f.name),
...listCollections(this.app, this.projectPath).map((f) => f.name),
];
new NameModal(this.app, {
title: "Neues Sub-Projekt",
label: "Projektname",
cta: "Erstellen",
validate: (n) => validateName(n, taken),
onSubmit: async (name) => {
await createProject(this.app, subProjectPath(this.projectPath, name));
await this.render(); await this.render();
}, },
}).open(); }).open();
@@ -442,17 +469,19 @@ export class ProjectDetailsView extends ItemView {
}).open(); }).open();
} }
private openRenameCollectionAt(folderPath: string, current: string, taken: string[]): void { private openRenameChildFolder(folder: TFolder, taken: string[]): void {
const current = folder.name;
new NameModal(this.app, { new NameModal(this.app, {
title: "Collection umbenennen", title: "Umbenennen",
label: "Collection-Name", label: "Name",
initial: current, initial: current,
cta: "Speichern", cta: "Speichern",
validate: (n) => validateName(n, taken, current), validate: (n) => validateName(n, taken, current),
onSubmit: async (name) => { onSubmit: async (name) => {
if (name === current) return; if (name === current) return;
const parent = folderPath.substring(0, folderPath.lastIndexOf("/")); const parent = folder.path.substring(0, folder.path.lastIndexOf("/"));
await rename(this.app, folderPath, normalizePath(`${parent}/${name}`)); const newPath = parent ? normalizePath(`${parent}/${name}`) : normalizePath(name);
await rename(this.app, folder.path, newPath);
await this.render(); await this.render();
}, },
}).open(); }).open();
@@ -463,11 +492,19 @@ export class ProjectDetailsView extends ItemView {
await this.render(); await this.render();
} }
private async openCollectionDetails(collection: string, zone: Zone): Promise<void> { private async openCollectionDetails(collection: string): Promise<void> {
await this.leaf.setViewState({ await this.leaf.setViewState({
type: VIEW_TYPE_COLLECTION_VIEW, type: VIEW_TYPE_COLLECTION_VIEW,
active: true, active: true,
state: { project: this.project, collection, zone }, state: { projectPath: this.projectPath, collection },
});
}
private async openProjectDetails(projectPath: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { projectPath },
}); });
} }

View File

@@ -12,10 +12,7 @@ import {
CORE_FILE, CORE_FILE,
} from "../const"; } from "../const";
import { import {
projectsPath, listTopLevelProjects,
projectPath,
listFolders,
ensureFolder,
createProject, createProject,
rename, rename,
deleteRecursive, deleteRecursive,
@@ -54,15 +51,12 @@ export class ProjectView extends ItemView {
} }
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
await ensureFolder(this.app, projectsPath());
await this.render(); await this.render();
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) => { this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path.endsWith("/" + CORE_FILE) && f.path.startsWith(projectsPath() + "/")) { if (f.path.endsWith("/" + CORE_FILE)) this.render();
this.render();
}
})); }));
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => { this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented) return;
@@ -79,7 +73,7 @@ export class ProjectView extends ItemView {
const token = ++this.renderToken; const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement; const root = this.containerEl.children[1] as HTMLElement;
const projects = listFolders(this.app, projectsPath()); const projects = listTopLevelProjects(this.app);
if (projects.length === 0) { if (projects.length === 0) {
if (token !== this.renderToken) return; if (token !== this.renderToken) return;
root.empty(); root.empty();
@@ -90,7 +84,7 @@ export class ProjectView extends ItemView {
} }
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 corePaths = projects.map((p) => normalizePath(`${p.path}/${CORE_FILE}`));
const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p))); const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p)));
if (token !== this.renderToken) return; if (token !== this.renderToken) return;
@@ -124,7 +118,7 @@ export class ProjectView extends ItemView {
} }
private openCreate(): void { private openCreate(): void {
const taken = listFolders(this.app, projectsPath()).map((p) => p.name); const taken = listTopLevelProjects(this.app).map((p) => p.name);
new NameModal(this.app, { new NameModal(this.app, {
title: "Neues Projekt", title: "Neues Projekt",
label: "Projektname", label: "Projektname",
@@ -152,7 +146,7 @@ export class ProjectView extends ItemView {
onSubmit: async (name) => { onSubmit: async (name) => {
if (name === current) return; if (name === current) return;
try { try {
await rename(this.app, projectPath(current), projectPath(name)); await rename(this.app, current, 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}`);
@@ -163,7 +157,7 @@ export class ProjectView extends ItemView {
private async openDelete(name: string): Promise<void> { private async openDelete(name: string): Promise<void> {
try { try {
await deleteRecursive(this.app, projectPath(name)); await deleteRecursive(this.app, 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}`);
@@ -174,7 +168,7 @@ export class ProjectView extends ItemView {
await this.leaf.setViewState({ await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW, type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true, active: true,
state: { project: name }, state: { projectPath: name },
}); });
} }
} }

View File

@@ -257,8 +257,6 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.pk-zone-divider { .pk-sub-project-card {
border: 0; border-left: 3px solid var(--text-accent);
border-top: 1px solid var(--background-modifier-border);
margin: 4px 0;
} }