Compare commits

...

2 Commits

Author SHA1 Message Date
Marek
6ea56fb5e0 update 2026-05-01 03:33:43 +02:00
Marek
a723f1ea0f update 2026-04-30 21:39:58 +02:00
23 changed files with 375 additions and 124 deletions

1
doc/_core.md Normal file
View File

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

24
doc/_project.md Normal file
View File

@@ -0,0 +1,24 @@
**Core**
- Obsidian Plugin - Projektkontext managen
**Target**
- Für große Projekte geht der Kontext schnell verloren.
- Sie werden unübersichtlich und schwer zu managen.
- Das Plugin hilft Projekte kontextbezogen zu strukturieren.
- So lassen sich große Projekte wieder managen.
**Story**
- Großes Projekt soll geplannt werden
- Core definieren (Worum geht es?)
- Target beschreiben (Welches Problem löst es?)
- Story schreiben (Wie wird es verwendet?)
- Areas und Features definieren (Welche Funktionen hat es?)
- Features implementieren
- Projekt v1
**Areas**
- **Core**: Kompakter Titel zum Projekt
- **Target**: Problem -> Folge -> Lösung -> Ergebnis zum Projekt
- **Story**: Kernverwendung vom Projekt
- **Area**: Sammlung von Features
- **Feature**: Abgeschlossene Funktion im Bereich
- **Projects**: Übersicht der Projekte
- **Overview**: Übersicht zu einem Projekt
- **Detail**: Übersicht zu einem Area

7
doc/_story.md Normal file
View File

@@ -0,0 +1,7 @@
- Großes Projekt soll geplannt werden
- Core definieren (Worum geht es?)
- Target beschreiben (Welches Problem löst es?)
- Story schreiben (Wie wird es verwendet?)
- Areas und Features definieren (Welche Funktionen hat es?)
- Features implementieren
- Projekt v1

2
doc/_target.md Normal file
View File

@@ -0,0 +1,2 @@
Für große Projekte geht der Kontext schnell verloren, sie werden unübersichtlich und schwer zu managen.
Das Plugin hilft Projekte kontextbezogen zu strukturieren, so lassen sich große Projekte wieder managen.

View File

@@ -1,3 +1,3 @@
- container für features
- Auflistung als Columns
- wird gespeichert als ./{area}/
- 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,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,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 +1,3 @@
Obsidian Plugin - Projektkontext managen
- Max 64 Zeichen
- wird als `./_core.md` gespeichert
- beschreibt kompakt was das projekt ist

View File

@@ -5,7 +5,7 @@ layout: features
features
- alle features zum area als cards
- LMB auf feature öffnet die {feature}.md
- LMB auf feature öffnet die `{feature}.md`
- RMB auf feature öffnet die feature optionen
feature option create

View File

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

View File

@@ -1,24 +1,9 @@
**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
- 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,7 +1,5 @@
- 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
- 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,2 +1,6 @@
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.
- 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

@@ -6,4 +6,9 @@ export const VIEW_TYPE_DETAILS = "projektkontext-details";
export const RIBBON_ICON = "layout-grid";
export const PROJECT_FILES = ["core.md", "target.md", "story.md"] as const;
export const PROJECT_FILE = "_project.md";
export const CORE_FILE = "_core.md";
export const TARGET_FILE = "_target.md";
export const STORY_FILE = "_story.md";
export const PROJECT_FILES = [PROJECT_FILE, CORE_FILE, TARGET_FILE, STORY_FILE] as const;

View File

@@ -94,6 +94,19 @@ export async function createFeature(
return await ensureFile(app, featurePath(project, area, feature), "");
}
export function projectFeaturePath(project: string, feature: string): string {
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
return normalizePath(`${PROJECTS_ROOT}/${project}/${file}`);
}
export async function createProjectFeature(
app: App,
project: string,
feature: string,
): Promise<TFile> {
return await ensureFile(app, projectFeaturePath(project, feature), "");
}
export function isProjectRootFile(name: string): boolean {
return (PROJECT_FILES as readonly string[]).includes(name);
}

View File

@@ -1,14 +1,19 @@
import { ItemView, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
import {
VIEW_TYPE_OVERVIEW,
VIEW_TYPE_DETAILS,
VIEW_TYPE_PROJECTS,
RIBBON_ICON,
PROJECT_FILES,
CORE_FILE,
TARGET_FILE,
STORY_FILE,
} from "../const";
import {
projectPath,
areaPath,
featurePath,
projectFeaturePath,
listFolders,
listMarkdownFiles,
readFile,
@@ -16,8 +21,9 @@ import {
deleteRecursive,
createArea,
createFeature,
createProjectFeature,
} from "../fs";
import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal";
@@ -26,6 +32,7 @@ export interface OverviewState extends Record<string, unknown> {
}
const NAME_RX = /^[^\\/:*?"<>|]+$/;
const FEATURE_DND_MIME = "application/x-pk-feature";
function validateName(name: string, taken: string[], current?: string): string | null {
if (!name) return "Name darf nicht leer sein.";
@@ -89,9 +96,9 @@ export class OverviewView extends ItemView {
}
const projRoot = projectPath(this.project);
const corePath = normalizePath(`${projRoot}/core.md`);
const targetPath = normalizePath(`${projRoot}/target.md`);
const storyPath = normalizePath(`${projRoot}/story.md`);
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
const storyPath = normalizePath(`${projRoot}/${STORY_FILE}`);
const [core, target, story] = await Promise.all([
readFile(this.app, corePath),
readFile(this.app, targetPath),
@@ -108,22 +115,24 @@ export class OverviewView extends ItemView {
]);
const stack = root.createDiv({ cls: "pk-stack" });
this.renderInfoCard(stack, "Core", core, corePath);
this.renderInfoCard(stack, "Target", target, targetPath);
this.renderInfoCard(stack, core, corePath);
this.renderInfoCard(stack, 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 renderInfoCard(parent: HTMLElement, content: string, path: string): void {
const btn = parent.createDiv({
cls: "pk-btn-card",
attr: { role: "button", tabindex: "0" },
});
btn.setText(content || "(leer)");
btn.addEventListener("click", () =>
openMarkdown(this.app, path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
}),
);
}
private renderStoryCard(parent: HTMLElement, content: string, path: string): void {
@@ -154,12 +163,36 @@ export class OverviewView extends ItemView {
ev.preventDefault();
menu(ev, [
{ title: "Neue Area", icon: "plus", onClick: () => this.openCreateArea() },
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
]);
});
section.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
section.addClass("pk-drop-zone");
});
section.addEventListener("dragleave", (ev) => {
const next = ev.relatedTarget as Node | null;
if (next && section.contains(next)) return;
section.removeClass("pk-drop-zone");
});
section.addEventListener("drop", async (ev) => {
section.removeClass("pk-drop-zone");
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
if (!raw) return;
ev.preventDefault();
await this.handleFeatureDropToProject(raw);
});
const areas = listFolders(this.app, projectPath(this.project));
if (areas.length === 0) {
emptyState(section, "Noch keine Areas. Rechtsklick → Neue Area.");
const projectFeatures = listMarkdownFiles(
this.app,
projectPath(this.project),
[...PROJECT_FILES],
);
if (areas.length === 0 && projectFeatures.length === 0) {
emptyState(section, "Noch leer. Rechtsklick → Neue Area / Neues Feature.");
return;
}
const takenAreas = areas.map((a) => a.name);
@@ -170,44 +203,188 @@ export class OverviewView extends ItemView {
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 },
});
const areaCard = stack.createDiv({
cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0" },
});
areaCard.createEl("strong", { text: area.name });
areaCard.addEventListener("click", () => this.openDetails(area.name));
areaCard.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) },
{ title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) },
{ title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) },
]);
});
areaCard.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
areaCard.addClass("pk-drop-target");
});
areaCard.addEventListener("dragleave", (ev) => {
const next = ev.relatedTarget as Node | null;
if (next && areaCard.contains(next)) return;
areaCard.removeClass("pk-drop-target");
});
areaCard.addEventListener("drop", async (ev) => {
areaCard.removeClass("pk-drop-target");
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
if (!raw) return;
ev.preventDefault();
ev.stopPropagation();
await this.handleFeatureDrop(raw, area.name);
});
if (features.length === 0) {
areaCard.createDiv({ cls: "pk-empty", text: "Keine Features." });
} else {
const list = areaCard.createDiv({ cls: "pk-features" });
for (const f of features) {
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
chip.draggable = true;
chip.addEventListener("dragstart", (ev) => {
if (!ev.dataTransfer) return;
ev.dataTransfer.setData(
FEATURE_DND_MIME,
JSON.stringify({ sourceArea: area.name, feature: f.basename }),
);
ev.dataTransfer.effectAllowed = "move";
chip.addClass("pk-feature-chip-dragging");
});
chip.addEventListener("dragend", () => {
chip.removeClass("pk-feature-chip-dragging");
});
chip.addEventListener("click", (ev) => {
ev.stopPropagation();
openMarkdown(this.app, f.path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
});
chip.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteFeature(area.name, f.basename) },
]);
});
}
},
});
chip.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteFeature(area.name, f.basename) },
]);
});
}
}
}
for (const f of projectFeatures) {
const fc = stack.createDiv({
cls: "pk-btn-card pk-area-card",
attr: { role: "button", tabindex: "0", draggable: "true" },
});
fc.createEl("strong", { text: f.basename });
fc.addEventListener("dragstart", (ev) => {
if (!ev.dataTransfer) return;
ev.dataTransfer.setData(
FEATURE_DND_MIME,
JSON.stringify({ sourceArea: null, feature: f.basename }),
);
ev.dataTransfer.effectAllowed = "move";
fc.addClass("pk-feature-chip-dragging");
});
fc.addEventListener("dragend", () => {
fc.removeClass("pk-feature-chip-dragging");
});
fc.addEventListener("click", () =>
openMarkdown(this.app, f.path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
}),
);
fc.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteProjectFeature(f.basename) },
]);
});
}
}
private openCreateProjectFeature(): void {
const taken = listMarkdownFiles(this.app, projectPath(this.project), []).map((f) => f.basename);
new NameModal(this.app, {
title: "Neues Feature",
label: "Feature-Name",
cta: "Erstellen",
validate: (n) => validateName(n, taken),
onSubmit: async (name) => {
await createProjectFeature(this.app, this.project, name);
await this.render();
},
}).open();
}
private openDeleteProjectFeature(feature: string): void {
new ConfirmModal(this.app, {
title: "Feature löschen",
message: `Feature „${feature}" wird gelöscht. Fortfahren?`,
cta: "Löschen",
destructive: true,
onConfirm: async () => {
await deleteRecursive(this.app, projectFeaturePath(this.project, feature));
await this.render();
},
}).open();
}
private async handleFeatureDropToProject(raw: string): Promise<void> {
let data: { sourceArea: string | null; feature: string };
try {
data = JSON.parse(raw);
} catch {
return;
}
if (!data?.feature) return;
if (data.sourceArea === null) return;
const newPath = projectFeaturePath(this.project, data.feature);
if (this.app.vault.getAbstractFileByPath(newPath)) {
new Notice(`${data.feature}" existiert im Projekt bereits.`);
return;
}
const oldPath = featurePath(this.project, data.sourceArea, data.feature);
try {
await rename(this.app, oldPath, newPath);
} catch (e) {
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
return;
}
await this.render();
}
private async handleFeatureDrop(raw: string, targetArea: string): Promise<void> {
let data: { sourceArea: string | null; feature: string };
try {
data = JSON.parse(raw);
} catch {
return;
}
if (!data?.feature) return;
if (data.sourceArea === targetArea) return;
const newPath = featurePath(this.project, targetArea, data.feature);
if (this.app.vault.getAbstractFileByPath(newPath)) {
new Notice(`${data.feature}" existiert in „${targetArea}" bereits.`);
return;
}
const oldPath = data.sourceArea === null
? projectFeaturePath(this.project, data.feature)
: featurePath(this.project, data.sourceArea, data.feature);
try {
await rename(this.app, oldPath, newPath);
} catch (e) {
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
return;
}
await this.render();
}
private openCreateArea(): void {
const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name);
new NameModal(this.app, {

View File

@@ -1,5 +1,5 @@
import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } from "../const";
import { ItemView, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON, CORE_FILE } from "../const";
import {
projectsPath,
projectPath,
@@ -8,8 +8,9 @@ import {
createProject,
rename,
deleteRecursive,
readFile,
} from "../fs";
import { card, menu, breadcrumb, emptyState } from "../ui";
import { menu, breadcrumb, emptyState } from "../ui";
import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal";
@@ -45,6 +46,11 @@ export class ProjectsView extends ItemView {
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();
}
}));
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return;
ev.preventDefault();
@@ -70,16 +76,29 @@ export class ProjectsView extends ItemView {
}
const taken = projects.map((p) => p.name);
const cores = await Promise.all(
projects.map((p) =>
readFile(this.app, normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)),
),
);
const grid = root.createDiv({ cls: "pk-grid" });
for (const proj of projects) {
card(grid, {
title: proj.name,
onClick: () => this.openOverview(proj.name),
onContextMenu: (ev) =>
menu(ev, [
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
]),
for (let i = 0; i < projects.length; i++) {
const proj = projects[i];
const core = cores[i].trim();
const btn = grid.createDiv({
cls: "pk-btn-card",
attr: { role: "button", tabindex: "0" },
});
btn.createEl("strong", { text: proj.name });
if (core) btn.createDiv({ text: core });
btn.addEventListener("click", () => this.openOverview(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) },
]);
});
}
}

View File

@@ -136,10 +136,47 @@
font-weight: 400;
}
.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-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 {
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px;
}
.pk-areas-flex {