feat(tags): is_parent-Flag zur Unterscheidung von Gruppierungs-Tags und zuordenbaren Tags

Parent-Tags (is_parent=true) dienen als Gruppierungsknoten und können nicht
Gesprächsnotizen zugeordnet werden. Echte Tags (is_parent=false) sind
zuordenbar. Die Bearbeitungsmaske bietet eine Checkbox für das Flag, die
Eltern-Auswahl ist auf Parent-Tags beschränkt. Die Tag-Liste zeigt
Parent-Tags mit Folder-Icon, echte Tags mit Label-Icon. Die Suche filtert
den gesamten Teilbaum — nur Knoten mit Self-Match oder einem matchenden
Nachfahren werden angezeigt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-19 10:40:39 +02:00
1 parent 3361ef16dd
commit b44ea806ef
1 file changed
+145 -28
+145 -28
View File
@@ -22,6 +22,7 @@ export interface iTag {
description: string; // Beschreibung
color: string; // Farbcode (z. B. "#ff0000")
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
is_parent: boolean; // true = Gruppierungs-Knoten (nicht zuordenbar), false = echter Tag (zuordenbar)
icon?: string; // Optional: Icon-Name oder URL
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
}
@@ -49,6 +50,7 @@ const tagDaten: iTag[] = [
description: "Hochpriorisierte Aufgaben",
color: "#ff0000",
parentId: null,
is_parent: false,
icon: "priority_high",
order: 1,
},
@@ -58,6 +60,7 @@ const tagDaten: iTag[] = [
description: "Kundenbezogene Tags",
color: "#00ff00",
parentId: null,
is_parent: false,
icon: "person",
order: 2,
},
@@ -67,6 +70,7 @@ const tagDaten: iTag[] = [
description: "Projektbezogene Tags",
color: "#0000ff",
parentId: null,
is_parent: false,
icon: "folder",
order: 3,
},
@@ -76,6 +80,7 @@ const tagDaten: iTag[] = [
description: "Nachverfolgung erforderlich",
color: "#ffff00",
parentId: null,
is_parent: false,
icon: "schedule",
order: 4,
},
@@ -85,6 +90,7 @@ const tagDaten: iTag[] = [
description: "Archivierte Einträge",
color: "#808080",
parentId: null,
is_parent: false,
icon: "archive",
order: 5,
},
@@ -96,6 +102,7 @@ const tagDaten: iTag[] = [
description: "Server-seitige Technologien und Frameworks",
color: "#4285F4",
parentId: null,
is_parent: true,
icon: "server",
order: 6,
},
@@ -105,6 +112,7 @@ const tagDaten: iTag[] = [
description: "Client-seitige Technologien und Frameworks",
color: "#34A853",
parentId: null,
is_parent: true,
icon: "browser",
order: 7,
},
@@ -114,6 +122,7 @@ const tagDaten: iTag[] = [
description: "Infrastruktur, Deployment und CI/CD",
color: "#EA4335",
parentId: null,
is_parent: true,
icon: "cog",
order: 8,
},
@@ -123,6 +132,7 @@ const tagDaten: iTag[] = [
description: "Datenbanken, Analyse und KI",
color: "#FBBC04",
parentId: null,
is_parent: true,
icon: "database",
order: 9,
},
@@ -134,6 +144,7 @@ const tagDaten: iTag[] = [
description: "Allgemeine Python-Entwicklung",
color: "#3776AB",
parentId: 100,
is_parent: true,
icon: "python",
order: 1,
},
@@ -143,6 +154,7 @@ const tagDaten: iTag[] = [
description: "Java und JVM-Ökosystem",
color: "#007396",
parentId: 100,
is_parent: false,
icon: "java",
order: 2,
},
@@ -152,6 +164,7 @@ const tagDaten: iTag[] = [
description: "JavaScript auf dem Server",
color: "#339933",
parentId: 100,
is_parent: true,
icon: "nodejs",
order: 3,
},
@@ -163,6 +176,7 @@ const tagDaten: iTag[] = [
description: "Mikro-Webframework für Python",
color: "#000000",
parentId: 200,
is_parent: false,
icon: "flask",
order: 1,
},
@@ -172,6 +186,7 @@ const tagDaten: iTag[] = [
description: "Full-Stack Webframework für Python",
color: "#092E20",
parentId: 200,
is_parent: false,
icon: "django",
order: 2,
},
@@ -181,6 +196,7 @@ const tagDaten: iTag[] = [
description: "Moderne API-Entwicklung mit Python",
color: "#005577",
parentId: 200,
is_parent: false,
icon: "fastapi",
order: 3,
},
@@ -192,6 +208,7 @@ const tagDaten: iTag[] = [
description: "Minimalistisches Webframework für Node.js",
color: "#000000",
parentId: 202,
is_parent: false,
icon: "express",
order: 1,
},
@@ -201,6 +218,7 @@ const tagDaten: iTag[] = [
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
color: "#E0234E",
parentId: 202,
is_parent: false,
icon: "nestjs",
order: 2,
},
@@ -212,6 +230,7 @@ const tagDaten: iTag[] = [
description: "Backend-Datenbank-Systeme",
color: "#336791",
parentId: 100,
is_parent: true,
icon: "database",
order: 4,
},
@@ -221,6 +240,7 @@ const tagDaten: iTag[] = [
description: "Relationale Datenbank mit erweiterten Features",
color: "#336791",
parentId: 210,
is_parent: false,
icon: "postgres",
order: 1,
},
@@ -230,6 +250,7 @@ const tagDaten: iTag[] = [
description: "Dokumentenorientierte NoSQL-Datenbank",
color: "#47A248",
parentId: 210,
is_parent: false,
icon: "mongodb",
order: 2,
},
@@ -241,6 +262,7 @@ const tagDaten: iTag[] = [
description: "Die Hauptsprache des Web",
color: "#F7DF1E",
parentId: 101,
is_parent: true,
icon: "js",
order: 1,
},
@@ -250,6 +272,7 @@ const tagDaten: iTag[] = [
description: "Typsicheres JavaScript",
color: "#3178C6",
parentId: 101,
is_parent: false,
icon: "ts",
order: 2,
},
@@ -261,6 +284,7 @@ const tagDaten: iTag[] = [
description: "Komponentenbasiertes UI-Framework",
color: "#61DAFB",
parentId: 220,
is_parent: false,
icon: "react",
order: 1,
},
@@ -270,6 +294,7 @@ const tagDaten: iTag[] = [
description: "Progressives JavaScript-Framework",
color: "#4FC08D",
parentId: 220,
is_parent: false,
icon: "vue",
order: 2,
},
@@ -279,6 +304,7 @@ const tagDaten: iTag[] = [
description: "Full-Stack Framework von Google",
color: "#DD1B16",
parentId: 220,
is_parent: false,
icon: "angular",
order: 3,
},
@@ -290,6 +316,7 @@ const tagDaten: iTag[] = [
description: "Stildefinition für Webseiten",
color: "#1572B6",
parentId: 101,
is_parent: true,
icon: "css",
order: 3,
},
@@ -299,6 +326,7 @@ const tagDaten: iTag[] = [
description: "Utility-first CSS-Framework",
color: "#06B6D4",
parentId: 230,
is_parent: false,
icon: "tailwind",
order: 1,
},
@@ -310,6 +338,7 @@ const tagDaten: iTag[] = [
description: "Containerisierung von Anwendungen",
color: "#2496ED",
parentId: 102,
is_parent: false,
icon: "docker",
order: 1,
},
@@ -319,6 +348,7 @@ const tagDaten: iTag[] = [
description: "Orchestrierung von Containern",
color: "#326CE5",
parentId: 102,
is_parent: false,
icon: "kubernetes",
order: 2,
},
@@ -328,6 +358,7 @@ const tagDaten: iTag[] = [
description: "Continuous Integration und Deployment",
color: "#FFA500",
parentId: 102,
is_parent: false,
icon: "cicd",
order: 3,
},
@@ -339,6 +370,7 @@ const tagDaten: iTag[] = [
description: "Machine Learning und KI-Technologien",
color: "#8B00FF",
parentId: 103,
is_parent: false,
icon: "ai",
order: 1,
},
@@ -348,6 +380,7 @@ const tagDaten: iTag[] = [
description: "Verarbeitung großer Datenmengen",
color: "#FF6B6B",
parentId: 103,
is_parent: false,
icon: "bigdata",
order: 2,
},
@@ -417,6 +450,45 @@ function getDescendants(tagId: number): iTag[] {
return descendants;
}
/**
* Liefert alle echten (zuordenbaren) Tags, d. h. alle Tags, die keine
* Parent-Tags sind (is_parent === false).
*/
function getRealTags(): iTag[] {
return tagDaten.filter((tag) => !tag.is_parent);
}
/**
* Liefert alle echten (zuordenbaren) Tags, die Nachfahren eines Parent-Tags
* sind (flach, rekursiv). Parent-Tags selbst werden herausgefiltert.
*/
function getRealDescendants(parentId: number): iTag[] {
return getDescendants(parentId).filter((tag) => !tag.is_parent);
}
/**
* Löst eine Tag-Auswahl auf echte (zuordenbare) Tag-IDs auf.
* Wurde ein Parent-Tag ausgewählt, werden alle echten Nachfahren geliefert.
* Wurde ein echter Tag ausgewählt, wird dessen eigene ID geliefert.
* Rückgabe der Tag-IDs (für Filter/Auswertungen).
*/
function resolveToRealTagIds(tagId: number): number[] {
const tag = findTagById(tagId);
if (!tag) return [];
if (tag.is_parent) {
return getRealDescendants(tagId).map((t) => t.id);
}
return [tagId];
}
/**
* Prüft, ob ein Tag zuordenbar ist, d. h. ein echter Tag (kein Parent-Tag).
*/
function isAssignable(tagId: number): boolean {
const tag = findTagById(tagId);
return tag ? !tag.is_parent : false;
}
/**
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
*/
@@ -444,7 +516,7 @@ function searchTagsByName(query: string): iTag[] {
/**
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
*/
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number): iTag {
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number, is_parent: boolean = false): iTag {
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
const tag: iTag = {
id: newId,
@@ -452,6 +524,7 @@ function addTag(name: string, parentId: number | null = null, description: strin
description,
color,
parentId,
is_parent,
icon,
order,
};
@@ -501,29 +574,41 @@ export const Tag: m.ClosureComponent = (vnode) => {
description: "",
color: "",
parentId: null,
is_parent: false,
};
const setColor = (color: string) => {
currentTag.color = color;
};
// Liste aller Tags für die Eltern-Auswahl
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
// Liste aller Parent-Tags für die Eltern-Auswahl (nur Parent-Tags können übergeordnet sein)
let allParentTags: iTag[] = [];
return {
oninit: (vnode) => {
const routeParams = m.route.param();
if (routeParams) {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1;
if (id !== -1) {
// Bestehenden Tag aus den Daten laden (frisch, nicht aus Route-Params)
const tag = findTagById(id);
if (tag) {
currentTag = {...tag};
}
} else if (routeParams) {
// Neuer Tag: vorbelegte Werte aus Route-Params übernehmen
currentTag = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
id: -1,
name: routeParams.name || "",
description: routeParams.description || "",
color: routeParams.color || "",
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
icon: routeParams.icon || undefined,
order: routeParams.order ? parseInt(routeParams.order) : undefined,
};
}
// Parent-Tag-Liste erst hier berechnen, da currentTag.id jetzt bekannt ist
allParentTags = tagDaten.filter(t => t.id !== currentTag.id && t.is_parent);
},
view: (vnode) => {
return m("div.w3-container.w3-auto.form-view-container",
@@ -533,13 +618,28 @@ export const Tag: m.ClosureComponent = (vnode) => {
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text"} as iInput)),
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
m("div", {style: {'margin-bottom': '5px'}},
m("label",
m("input.w3-check", {
type: "checkbox",
name: "is_parent",
checked: currentTag.is_parent,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.is_parent = (e.target as HTMLInputElement).checked;
}
}),
"Nur zur Gruppierung, kann nicht zugeordnet werden"
)
),
// Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)",
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
)
m("label", "Übergeordneter Tag (optional)"),
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
)
),
@@ -559,9 +659,6 @@ export const Tag: m.ClosureComponent = (vnode) => {
})
)
),
m(Input, {name: "color", value: currentTag.color, type: "text"} as iInput),
m("label", "Icon (optional)", m(Input, {name: "icon", value: currentTag.icon || "", type: "text"} as iInput)),
m("label", "Sortierreihenfolge (optional)", m(Input, {name: "order", value: currentTag.order?.toString() || "", type: "number"} as iInput)),
])
)
);
@@ -585,22 +682,26 @@ export const TagList: m.ClosureComponent = (vnode) => {
const editTag = (tag: iTag, e: Event) => {
e.stopPropagation();
m.route.set("/tag", {
id: tag.id,
name: tag.name,
description: tag.description,
color: tag.color,
parentId: tag.parentId,
icon: tag.icon,
order: tag.order
});
m.route.set("/tag", {id: tag.id});
};
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let tag: iTag = node.nodevalue;
// Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
const tagIcon = tag.is_parent ? "folder" : "label";
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : "";
return [
m("span",
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '6px',
fontSize: '18px'
},
title: tagTitle
}, tagIcon),
m("span", {
style: {
backgroundColor: tag.color,
@@ -610,7 +711,8 @@ export const TagList: m.ClosureComponent = (vnode) => {
fontWeight: 'bold',
display: 'inline-block',
marginRight: '8px'
}
},
title: tagTitle
}, tag.name)
),
m("span", tag.description),
@@ -625,10 +727,21 @@ export const TagList: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue;
let searchLower = searchvalue.toLowerCase();
return tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower) ||
tag.color.toLowerCase().includes(searchLower) ||
(tag.icon && tag.icon.toLowerCase().includes(searchLower));
// Prüft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower);
// Wenn der Tag selbst passt, anzeigen
if (selfMatch) return true;
// Wenn der Tag Kinder hat, prüfen, ob ein Nachfahne passt
if (searchLower === "") return true;
const descendants = getDescendants(tag.id);
return descendants.some((d) =>
d.name.toLowerCase().includes(searchLower) ||
d.description.toLowerCase().includes(searchLower)
);
};
return {
@@ -675,4 +788,8 @@ export {
addTag,
updateTag,
getRootTags,
getRealTags,
getRealDescendants,
resolveToRealTagIds,
isAssignable,
};