From b44ea806efb57ea84e4cfb102106a057b29d0fa8 Mon Sep 17 00:00:00 2001 From: Gerd Date: Sat, 19 Sep 2026 10:40:39 +0200 Subject: [PATCH] feat(tags): is_parent-Flag zur Unterscheidung von Gruppierungs-Tags und zuordenbaren Tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Client/Components/tags.ts | 173 ++++++++++++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 28 deletions(-) diff --git a/Client/Components/tags.ts b/Client/Components/tags.ts index 1ffcce5..bd30233 100644 --- a/Client/Components/tags.ts +++ b/Client/Components/tags.ts @@ -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, };