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 { ProjectDetailsView } from "./src/views/ProjectDetailsView";
import { CollectionView } from "./src/views/CollectionView";
import { parseProjectFilePath } from "./src/fs";
import { parseProjectFilePath, projectPathFromChain } from "./src/fs";
import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui";
export default class ProjektkontextPlugin extends Plugin {
@@ -63,7 +63,7 @@ export default class ProjektkontextPlugin extends Plugin {
injectMobileBreadcrumb(view, []);
return;
}
const loc = parseProjectFilePath(file.path);
const loc = parseProjectFilePath(this.app, file.path);
if (!loc) {
injectMobileBreadcrumb(view, []);
return;
@@ -73,24 +73,28 @@ export default class ProjektkontextPlugin extends Plugin {
label: "Projekte",
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: () =>
void leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { project: loc.project },
state: { projectPath },
}),
},
];
});
}
if (loc.collection) {
const projectPath = projectPathFromChain(loc.projectChain);
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 },
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_DETAILS_VIEW = "projektkontext-overview";
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 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 { 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";
export function projectsPath(): string {
return PROJECTS_ROOT;
function hasFile(app: App, folderPath: string, fileName: string): boolean {
const p = normalizePath(`${folderPath}/${fileName}`);
return app.vault.getAbstractFileByPath(p) instanceof TFile;
}
export function projectPath(name: string): string {
return normalizePath(`${PROJECTS_ROOT}/${name}`);
export function projectFolder(projectPath: string): string {
return normalizePath(projectPath);
}
export function toUpdatePath(project: string): string {
return normalizePath(`${PROJECTS_ROOT}/${project}/${TO_UPDATE_DIR}`);
export function subProjectPath(parent: string, name: string): string {
return normalizePath(`${parent}/${name}`);
}
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 collectionPath(projectPath: string, collection: string): string {
return normalizePath(`${projectPath}/${collection}`);
}
export function featurePath(
project: string,
projectPath: string,
collection: string,
feature: string,
zone: Zone = "ready",
): string {
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> {
@@ -54,16 +66,32 @@ export async function ensureFile(app: App, path: string, content = ""): Promise<
return await app.vault.create(p, content);
}
export function listFolders(app: App, path: string): TFolder[] {
const p = normalizePath(path);
const folder = app.vault.getAbstractFileByPath(p);
if (!(folder instanceof TFolder)) return [];
function visibleFolders(folder: TFolder): TFolder[] {
return folder.children
.filter((c): c is TFolder => c instanceof TFolder)
.filter((c) => !c.name.startsWith("_") || c.name.startsWith("__"))
.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[] {
const p = normalizePath(path);
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));
}
export function listProjectFeatures(app: App, projectPath: string): TFile[] {
return listMarkdownFiles(app, projectPath, [...PROJECT_FILES]);
}
export async function readFile(app: App, path: string): Promise<string> {
const p = normalizePath(path);
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));
}
export async function createProject(app: App, name: string): Promise<void> {
const root = projectPath(name);
await ensureFolder(app, projectsPath());
export async function createProject(app: App, projectPath: string): Promise<void> {
const root = normalizePath(projectPath);
await ensureFolder(app, root);
for (const file of PROJECT_FILES) {
await ensureFile(app, normalizePath(`${root}/${file}`), "");
}
await createCollection(app, name, "story");
await ensureFile(app, normalizePath(`${root}/${CORE_FILE}`), "");
await ensureFile(app, normalizePath(`${root}/${DESCRIPTION_FILE}`), "");
}
export async function createCollection(
app: App,
project: string,
projectPath: 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));
await ensureFolder(app, collectionPath(projectPath, collection));
}
export async function createFeature(
app: App,
project: string,
projectPath: string,
collection: string,
feature: string,
zone: Zone = "ready",
): Promise<TFile> {
return await ensureFile(app, featurePath(project, collection, feature, zone), "");
}
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}`);
return await ensureFile(app, featurePath(projectPath, collection, feature), "");
}
export async function createProjectFeature(
app: App,
project: string,
projectPath: 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), "");
return await ensureFile(app, projectFeaturePath(projectPath, feature), "");
}
export interface ProjectFileLocation {
project: string;
projectChain: 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;
const parts = normalizePath(path).split("/");
if (parts[0] !== PROJECTS_ROOT) return null;
if (parts.length < 3) return null;
const project = parts[1];
const rest = parts.slice(2);
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 (parts.length < 2) return null;
const segments = parts.slice(0, -1);
if (segments.length === 0) return null;
const projectChain: string[] = [segments[0]];
let collection: string | undefined;
for (let i = 1; i < segments.length; i++) {
const prefix = segments.slice(0, i + 1).join("/");
if (isCollectionFolder(app, prefix)) {
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,
} from "../const";
import {
Zone,
collectionPath,
featurePath,
listMarkdownFiles,
@@ -14,13 +13,12 @@ import {
createFeature,
deleteRecursive,
} from "../fs";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui";
import { NameModal } from "../modals/NameModal";
export interface CollectionViewState extends Record<string, unknown> {
project: string;
projectPath: string;
collection: string;
zone?: Zone;
}
const NAME_RX = /^[^\\/:*?"<>|]+$/;
@@ -33,9 +31,8 @@ function validateName(name: string, taken: string[]): string | null {
}
export class CollectionView extends ItemView {
project = "";
projectPath = "";
collection = "";
zone: Zone = "ready";
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) {
@@ -56,15 +53,14 @@ export class CollectionView extends ItemView {
}
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";
this.projectPath = state?.projectPath ?? "";
this.collection = state?.collection ?? "";
await super.setState(state, result);
await this.render();
}
getState(): CollectionViewState {
return { project: this.project, collection: this.collection, zone: this.zone };
return { projectPath: this.projectPath, collection: this.collection };
}
async onOpen(): Promise<void> {
@@ -87,7 +83,7 @@ export class CollectionView extends ItemView {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement;
if (!this.project || !this.collection) {
if (!this.projectPath || !this.collection) {
root.empty();
root.addClass("pk-root");
emptyState(root, "Keine Collection ausgewählt.");
@@ -96,7 +92,7 @@ export class CollectionView extends ItemView {
const features = listMarkdownFiles(
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)));
@@ -105,11 +101,7 @@ export class CollectionView extends ItemView {
root.empty();
root.addClass("pk-root");
breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectView() },
{ label: this.project, onClick: () => this.openProjectDetails() },
{ label: this.collection },
]);
this.renderBreadcrumb(root);
if (features.length === 0) {
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 {
const taken = listMarkdownFiles(
this.app,
collectionPath(this.project, this.collection, this.zone),
collectionPath(this.projectPath, this.collection),
[],
).map((f) => f.basename);
new NameModal(this.app, {
@@ -153,7 +161,7 @@ export class CollectionView extends ItemView {
cta: "Erstellen",
validate: (n) => validateName(n, taken),
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();
},
}).open();
@@ -162,16 +170,16 @@ export class CollectionView extends ItemView {
private async openDelete(feature: string): Promise<void> {
await deleteRecursive(
this.app,
featurePath(this.project, this.collection, feature, this.zone),
featurePath(this.projectPath, this.collection, feature),
);
await this.render();
}
private async openProjectDetails(): Promise<void> {
private async openProjectDetails(projectPath: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { project: this.project },
state: { projectPath },
});
}

View File

@@ -13,35 +13,36 @@ import {
VIEW_TYPE_COLLECTION_VIEW,
VIEW_TYPE_PROJECT_VIEW,
RIBBON_ICON,
PROJECT_FILES,
CORE_FILE,
TARGET_FILE,
DESCRIPTION_FILE,
} from "../const";
import {
Zone,
projectPath,
zoneRootPath,
listFolders,
projectFolder,
subProjectPath,
collectionPath,
listSubProjects,
listCollections,
listProjectFeatures,
listMarkdownFiles,
readFile,
rename,
deleteRecursive,
ensureFolder,
createCollection,
createProject,
createProjectFeature,
} from "../fs";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui";
import { NameModal } from "../modals/NameModal";
export interface ProjectDetailsState extends Record<string, unknown> {
project: string;
projectPath: string;
}
const NAME_RX = /^[^\\/:*?"<>|]+$/;
const PK_DND_MIME = "application/x-pk-item";
interface DndPayload {
kind: "feature" | "collection";
kind: "feature";
sourcePath: string;
name: string;
}
@@ -54,7 +55,7 @@ function validateName(name: string, taken: string[], current?: string): string |
}
export class ProjectDetailsView extends ItemView {
project = "";
projectPath = "";
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) {
@@ -67,7 +68,9 @@ export class ProjectDetailsView extends ItemView {
}
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 {
@@ -75,13 +78,13 @@ export class ProjectDetailsView extends ItemView {
}
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? "";
this.projectPath = state?.projectPath ?? "";
await super.setState(state, result);
await this.render();
}
getState(): ProjectDetailsState {
return { project: this.project };
return { projectPath: this.projectPath };
}
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("rename", () => this.render()));
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 root = this.containerEl.children[1] as HTMLElement;
if (!this.project) {
if (!this.projectPath) {
root.empty();
root.addClass("pk-root");
emptyState(root, "Kein Projekt ausgewählt.");
return;
}
const projRoot = projectPath(this.project);
const projRoot = projectFolder(this.projectPath);
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
const [core, target] = await Promise.all([
const descPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`);
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, targetPath),
readFile(this.app, descPath),
...subCorePaths.map((p) => readFile(this.app, p)),
]);
if (token !== this.renderToken) return;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectView() },
{ label: this.project },
]);
this.renderBreadcrumb(root);
const info = root.createDiv({ cls: "pk-info-grid" });
this.renderInfoCard(info, core, corePath);
this.renderInfoCard(info, target, targetPath);
this.renderCollections(root);
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" });
for (const ic of infoCards) this.renderInfoCard(info, ic.content, ic.path);
}
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 {
@@ -136,60 +160,25 @@ export class ProjectDetailsView extends ItemView {
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)");
}
void MarkdownRenderer.render(this.app, content, btn, path, this);
}
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(
private renderChildren(
parent: HTMLElement,
zone: Zone,
items: { collections: TFolder[]; features: TFile[] },
subProjects: TFolder[],
subCorePaths: string[],
subCores: string[],
): void {
const flex = parent.createDiv({
cls: `pk-areas-flex pk-zone-${zone}`,
attr: { "data-zone": zone },
});
const collections = listCollections(this.app, this.projectPath);
const features = listProjectFeatures(this.app, this.projectPath);
const section = parent.createDiv({ cls: "pk-areas-section" });
const flex = section.createDiv({ cls: "pk-areas-flex" });
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) },
]);
this.openProjectLevelMenu(ev);
});
flex.addEventListener("dragover", (ev) => {
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);
if (!raw) return;
ev.preventDefault();
await this.handleDropOnZone(raw, zone);
await this.handleDropOnProjectRoot(raw);
});
const takenCollections = items.collections.map((a) => a.name);
for (const collection of items.collections) {
this.renderCollectionCard(flex, zone, collection, takenCollections);
const takenChildren = [
...subProjects.map((f) => f.name),
...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) {
this.renderProjectFeatureCard(flex, zone, f);
for (const col of collections) {
this.renderCollectionCard(flex, col, takenChildren);
}
if (items.collections.length === 0 && items.features.length === 0) {
flex.createDiv({ cls: "pk-zone-placeholder", text: this.zoneEmptyText(zone) });
for (const f of features) {
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(
parent: HTMLElement,
zone: Zone,
collection: TFolder,
takenCollections: string[],
takenChildren: 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" },
attr: { role: "button", tabindex: "0" },
});
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) => {
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: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() },
{ title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(collection, takenChildren) },
{ 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();
@@ -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({
cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0", draggable: "true" },
@@ -334,7 +355,7 @@ export class ProjectDetailsView extends ItemView {
ev.preventDefault();
ev.stopPropagation();
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) },
]);
});
@@ -344,30 +365,25 @@ export class ProjectDetailsView extends ItemView {
try {
const data = JSON.parse(raw);
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;
} catch {
return null;
}
}
private async handleDropOnZone(raw: string, zone: Zone): Promise<void> {
private async handleDropOnProjectRoot(raw: string): 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}`);
const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`;
const newPath = normalizePath(`${this.projectPath}/${file}`);
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;
@@ -388,38 +404,49 @@ export class ProjectDetailsView extends ItemView {
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);
private openCreateProjectFeature(): void {
const taken = listProjectFeatures(this.app, this.projectPath).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 createProjectFeature(this.app, this.projectPath, name);
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),
);
private openCreateCollection(): void {
const taken = [
...listCollections(this.app, this.projectPath).map((f) => f.name),
...listSubProjects(this.app, this.projectPath).map((f) => f.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 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();
},
}).open();
@@ -442,17 +469,19 @@ export class ProjectDetailsView extends ItemView {
}).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, {
title: "Collection umbenennen",
label: "Collection-Name",
title: "Umbenennen",
label: "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}`));
const parent = folder.path.substring(0, folder.path.lastIndexOf("/"));
const newPath = parent ? normalizePath(`${parent}/${name}`) : normalizePath(name);
await rename(this.app, folder.path, newPath);
await this.render();
},
}).open();
@@ -463,11 +492,19 @@ export class ProjectDetailsView extends ItemView {
await this.render();
}
private async openCollectionDetails(collection: string, zone: Zone): Promise<void> {
private async openCollectionDetails(collection: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_COLLECTION_VIEW,
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,
} from "../const";
import {
projectsPath,
projectPath,
listFolders,
ensureFolder,
listTopLevelProjects,
createProject,
rename,
deleteRecursive,
@@ -54,15 +51,12 @@ export class ProjectView extends ItemView {
}
async onOpen(): Promise<void> {
await ensureFolder(this.app, projectsPath());
await this.render();
this.registerEvent(this.app.vault.on("create", () => this.render()));
this.registerEvent(this.app.vault.on("delete", () => this.render()));
this.registerEvent(this.app.vault.on("rename", () => this.render()));
this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path.endsWith("/" + CORE_FILE) && f.path.startsWith(projectsPath() + "/")) {
this.render();
}
if (f.path.endsWith("/" + CORE_FILE)) this.render();
}));
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return;
@@ -79,7 +73,7 @@ export class ProjectView extends ItemView {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement;
const projects = listFolders(this.app, projectsPath());
const projects = listTopLevelProjects(this.app);
if (projects.length === 0) {
if (token !== this.renderToken) return;
root.empty();
@@ -90,7 +84,7 @@ export class ProjectView extends ItemView {
}
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)));
if (token !== this.renderToken) return;
@@ -124,7 +118,7 @@ export class ProjectView extends ItemView {
}
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, {
title: "Neues Projekt",
label: "Projektname",
@@ -152,7 +146,7 @@ export class ProjectView extends ItemView {
onSubmit: async (name) => {
if (name === current) return;
try {
await rename(this.app, projectPath(current), projectPath(name));
await rename(this.app, current, name);
await this.render();
} catch (e) {
new Notice(`Fehler: ${(e as Error).message}`);
@@ -163,7 +157,7 @@ export class ProjectView extends ItemView {
private async openDelete(name: string): Promise<void> {
try {
await deleteRecursive(this.app, projectPath(name));
await deleteRecursive(this.app, name);
await this.render();
} catch (e) {
new Notice(`Fehler: ${(e as Error).message}`);
@@ -174,7 +168,7 @@ export class ProjectView extends ItemView {
await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { project: name },
state: { projectPath: name },
});
}
}

View File

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