Repository navigation
agent: #10 [Agent] Suchen und Ersetzen im Kapitel und im ganzen Projekt #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
leonatwork
merged 5 commits into
main
from
agent/bearbeite-github-issue-10-vollst-ndig-un-1020-1159
Oct 25, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
38a8e81
agent: Bearbeite GitHub-Issue #10 vollständig und liefere eine review…
leonatwork 186853b
Merge main (Kapitel teilen/archivieren) in PR #21; Konflikte in Stati…
leonatwork cf29b0a
Rust-Formatierung (cargo fmt) für src-tauri
leonatwork c7c1ba5
Merge main (Skalierung, Kapitel-Archiv) in PR #21; Konflikte in Befeh…
leonatwork 1cb8a0c
Merge remote-tracking branch 'origin/main' into pr21-aktuell
leonatwork File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { readdirSync, readFileSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| describe('Volltext bleibt lokal und getrennt von KI', () => { | ||
| it('Core und Projektsuche importieren nur ihre expliziten öffentlichen Schnittstellen', () => { | ||
| const ordner = [resolve(__dirname), resolve(__dirname, '../../src/features/suche')]; | ||
| const erlaubt = new Set(['diff', '../model/types', '../text', 'react', '@core/suche', '@core/text', '@tiptap/core', '@tiptap/pm/state', '@tiptap/pm/view', '../../i18n', '../../state/store', '../../ui', '../../app/dialogTypen', '../schreiben/klartext', '../schreiben/markdown']); | ||
| for (const dir of ordner) for (const name of readdirSync(dir).filter(n => /\.tsx?$/.test(n) && !n.includes('.test.'))) { | ||
| const quelle = readFileSync(resolve(dir, name), 'utf8'); | ||
| expect(quelle).not.toMatch(/core\/ki|@core\/ki|@tauri-apps|fetch\(|KiTransport/); | ||
| for (const m of quelle.matchAll(/(?:from\s*|import\s*\()(['"])([^'"]+)\1/g)) { | ||
| expect(m[2]!.startsWith('./') || erlaubt.has(m[2]!), `${name}: ${m[2]}`).toBe(true); | ||
| } | ||
| } | ||
| const slice = readFileSync(resolve(__dirname, '../../src/state/slices/suche.ts'), 'utf8'); | ||
| expect(slice).not.toMatch(/core\/ki|@core\/ki|backend|fetch\(|KiTransport/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import type { ProjektDaten } from '../model/types'; | ||
| import { diffChars } from 'diff'; | ||
| import { klartext } from '../text'; | ||
|
|
||
| export interface SuchOptionen { ganzesWort?: boolean; caseSensitiv?: boolean } | ||
| export interface TextTreffer { start: number; ende: number; kontext: string } | ||
| export interface SuchFeld { pfad: (string | number)[]; text: string; bereich: 'manuskript' | 'plan' | 'notizen'; titel: string; kapitel?: string; nummer?: number; markdown: boolean } | ||
| export interface ProjektTreffer extends TextTreffer { feld: SuchFeld } | ||
| export function sucheText(text: string, suche: string, optionen: SuchOptionen = {}, markdown = false): TextTreffer[] { | ||
| if (!suche || /\n/.test(suche)) return []; | ||
| const t = markdown ? klartext(text) : text; | ||
| // Gleiche Syntax wie verweiseZerlegen in der UI; core bleibt unabhängig von src/. | ||
| // Verweise sind keine editierbaren Textspannen, auch ihre gespeicherte Anzeige nicht. | ||
| const verweise = markdown ? [] : [...t.matchAll(/\[\[(figur|ort|kapitel|plan|idee|gruppe):([^|\]\n]+)(?:\|([^\]\n]*))?\]\]/g)] | ||
| .map(m => ({ start: m.index, ende: m.index + m[0].length })); | ||
| const muster = suche.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const re = new RegExp(muster, optionen.caseSensitiv ? 'gu' : 'giu'); | ||
| const wort = /[\p{L}\p{N}\p{M}_]/u; | ||
| const result: TextTreffer[] = []; | ||
| for (const m of t.matchAll(re)) { | ||
| const start = m.index, ende = start + m[0].length; | ||
| if (verweise.some(v => start < v.ende && ende > v.start)) continue; | ||
| if (t.slice(start, ende).includes('\n\n')) continue; | ||
| if (optionen.ganzesWort && (wort.test([...t.slice(0, start)].at(-1) ?? '') || wort.test([...t.slice(ende)][0] ?? ''))) continue; | ||
| const vor = t.lastIndexOf('\n\n', start); | ||
| result.push({ start, ende, kontext: t.slice(Math.max(vor < 0 ? 0 : vor + 2, start - 48), Math.min(t.indexOf('\n\n', ende) < 0 ? t.length : t.indexOf('\n\n', ende), ende + 48)) }); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** Klartextoffset → Quellzeichen. Nur sichtbare Zeichen ändern, Auszeichnung bleibt erhalten. */ | ||
| function quellKarte(text: string): number[] { | ||
| let quelle = 0; | ||
| const karte: number[] = []; | ||
| for (const teil of diffChars(text, klartext(text))) { | ||
| if (teil.removed) quelle += teil.value.length; | ||
| else if (teil.added) for (let i = 0; i < teil.value.length; i++) karte.push(Math.max(0, quelle - 1)); | ||
| else for (let i = 0; i < teil.value.length; i++) karte.push(quelle++); | ||
| } | ||
| return karte; | ||
| } | ||
| export function ersetzeTreffer(text: string, treffer: TextTreffer[], ersatz: string, markdown = false): string { | ||
| const karte = markdown ? quellKarte(text) : Array.from({ length: text.length }, (_, i) => i); | ||
| const einfuegen = markdown ? ersatz.replace(/[\\*_#=\-]/g, '\\$&').replace(/\n/g, '\\\n') : ersatz; | ||
| for (const t of [...treffer].sort((a, b) => b.start - a.start)) { | ||
| const positionen = [...new Set(karte.slice(t.start, t.ende))].sort((a, b) => b - a); | ||
| // Entferne nur Auszeichnung, deren kompletter Inhalt gelöscht wird und in der kein | ||
| // Ersatz steht. Andernfalls würden leere **…**-Spannen zu sichtbaren Sternen werden. | ||
| if (markdown) { | ||
| const geloescht = new Set(positionen); | ||
| const einfuegePos = positionen.at(-1)!; | ||
| // Das Escape gehört zum sichtbaren Satzzeichen. Würde nur das Zeichen gelöscht, | ||
| // bliebe ein wörtlicher Backslash zurück oder maskierte den Ersatztext. | ||
| for (const pos of positionen) { | ||
| if (!/[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/.test(text[pos]!)) continue; | ||
| let davor = pos - 1; | ||
| while (davor >= 0 && text[davor] === '\\') davor--; | ||
| if ((pos - davor - 1) % 2 === 1) geloescht.add(pos - 1); | ||
| } | ||
| for (const m of text.matchAll(/(\*\*|__|\*|_)([^*_\n]+)\1/g)) { | ||
| const start = m.index + m[1]!.length, ende = start + m[2]!.length; | ||
| if (einfuegePos >= start && einfuegePos < ende && einfuegen) continue; | ||
| if (Array.from({ length: ende - start }, (_, i) => start + i).every(p => geloescht.has(p))) { | ||
| for (let i = 0; i < m[1]!.length; i++) { geloescht.add(m.index + i); geloescht.add(ende + i); } | ||
| } | ||
| } | ||
| positionen.splice(0, positionen.length, ...[...geloescht].sort((a, b) => b - a)); | ||
| for (const pos of positionen) text = text.slice(0, pos) + (pos === einfuegePos ? einfuegen : '') + text.slice(pos + 1); | ||
| continue; | ||
| } | ||
| for (const pos of positionen) text = text.slice(0, pos) + (pos === positionen.at(-1) ? einfuegen : '') + text.slice(pos + 1); | ||
| } | ||
| return text; | ||
| } | ||
| export function ersetzeText(text: string, suche: string, ersatz: string, optionen: SuchOptionen = {}, markdown = false): string { | ||
| return ersetzeTreffer(text, sucheText(text, suche, optionen, markdown), ersatz, markdown); | ||
| } | ||
|
|
||
| // Explizite Text-Allowlist: niemals IDs, Verweise, Enumwerte, Anker, KI-Ablagen oder Fassungen. | ||
| const TEXT = new Set(['titel', 'name', 'funktion', 'notiz', 'notizen', 'wahrheit', 'kurz', 'beschreibung', 'quintessenz', 'wendepunkt', 'wendung', 'text', 'alterPlan', 'autor', 'untertitel', 'genre']); | ||
| const LISTEN = new Set(['aliase', 'formen', 'ignoriert', 'varianten', 'schlagworte']); | ||
| const RECORDS = new Set(['plan', 'praesenz', 'rollen']); | ||
| const AUS = new Set(['anker', 'beleg', 'belege', 'zeitpunkt', 'netz']); | ||
| export function projektFelder(d: ProjektDaten): SuchFeld[] { | ||
| const result: SuchFeld[] = []; | ||
| const gehe = (wert: unknown, pfad: (string | number)[], titel: string, kapitel?: string, privat = false, frei = false) => { | ||
| if (typeof wert === 'string') { | ||
| if (frei) result.push({ pfad, text: wert, titel, kapitel, bereich: privat ? 'notizen' : 'plan', markdown: false }); | ||
| return; | ||
| } | ||
| if (Array.isArray(wert)) wert.forEach((x, i) => gehe(x, [...pfad, i], titel, kapitel, privat, frei)); | ||
| else if (wert && typeof wert === 'object') for (const [key, x] of Object.entries(wert)) { | ||
| if (AUS.has(key) && !frei) continue; | ||
| const spezial = (key === 'szene' && pfad[0] === 'plaene' && pfad.length === 2) || (key === 'art' && pfad.includes('beziehungen')) || (key === 'kategorie' && pfad[0] === 'marken'); | ||
| gehe(x, [...pfad, key], titel, kapitel, privat || ['notiz', 'notizen', 'wahrheit', 'alterPlan'].includes(key) || (pfad[0] === 'recherche' && key === 'text'), frei || spezial || TEXT.has(key) || LISTEN.has(key) || RECORDS.has(key)); | ||
| } | ||
| }; | ||
| d.projekt.kapitel.forEach((id, i) => { | ||
| const k = d.kapitel[id]; | ||
| if (k) result.push({ pfad: ['kapitel', id, 'text'], text: k.text, titel: k.titel, kapitel: id, nummer: i + 1, bereich: 'manuskript', markdown: true }); | ||
| if (d.plaene[id]) gehe(d.plaene[id], ['plaene', id], d.plaene[id]!.titel, id); | ||
| }); | ||
| for (const [id, p] of Object.entries(d.plaene)) if (!d.projekt.kapitel.includes(id)) gehe(p, ['plaene', id], p.titel, id); | ||
| for (const root of ['figuren', 'orte', 'gruppen'] as const) for (const [id, x] of Object.entries(d[root])) gehe(x, [root, id], x.name); | ||
| for (const root of ['akte', 'straenge', 'ideen', 'motive', 'zeitleiste', 'fadenplanung', 'boegen', 'marken', 'anmerkungen', 'recherche'] as const) gehe(d[root], [root], root); | ||
| gehe(d.projekt.notiz, ['projekt', 'notiz'], d.projekt.titel, undefined, true, true); | ||
| gehe(d.projekt.angaben, ['projekt', 'angaben'], d.projekt.titel); | ||
| gehe(d.entscheidungen.zusammenfassungen, ['entscheidungen', 'zusammenfassungen'], d.projekt.titel); | ||
| return result; | ||
| } | ||
| export function sucheProjekt(daten: ProjektDaten, suche: string, optionen: SuchOptionen = {}): ProjektTreffer[] { | ||
| return projektFelder(daten).flatMap(feld => sucheText(feld.text, suche, optionen, feld.markdown).map(t => ({ ...t, feld }))); | ||
| } | ||
|
|
||
| /** Unveränderliche Aktualisierung genau eines Textfelds. */ | ||
| export function feldSetzen(d: ProjektDaten, pfad: SuchFeld['pfad'], text: string): ProjektDaten { | ||
| const setze = (obj: unknown, keys: SuchFeld['pfad']): unknown => { | ||
| if (!keys.length) return text; | ||
| const [key, ...rest] = keys; | ||
| if (Array.isArray(obj)) return obj.map((x, i) => i === key ? setze(x, rest) : x); | ||
| const record = obj as Record<string, unknown>; | ||
| return { ...record, [key!]: setze(record[String(key)], rest) }; | ||
| }; | ||
| return setze(d, pfad) as ProjektDaten; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { ladeProjekt } from '../projekt'; | ||
| import { demoFs } from '../../fixtures/demoProjekt'; | ||
| import { sucheText, ersetzeText, projektFelder, sucheProjekt } from './index'; | ||
| import { klartext } from '../text'; | ||
|
|
||
| describe('Suchen und Ersetzen', () => { | ||
| it('ersetzt in Plan- und Notizfeldern wörtlich, ohne Markdown auszuwerten oder zu maskieren', async () => { | ||
| const d = await ladeProjekt(demoFs()); | ||
| d.figuren.paul!.notizen = '**Hafen**'; | ||
| d.orte.lindenhof!.notizen = '**Hafen**'; | ||
| d.plaene.flut!.szenen = [{ id: 'szene', kapitel: 'flut', index: 0, titel: 'Szene', figuren: [], notiz: '**Hafen**' }]; | ||
| d.recherche!.eintraege = [{ id: 'notiz', art: 'notiz', titel: 'Notiz', text: '**Hafen**', verweise: [], erstellt: '' }]; | ||
| const pfade = ['figuren.paul.notizen', 'orte.lindenhof.notizen', 'plaene.flut.szenen.0.notiz', 'recherche.eintraege.0.text']; | ||
| const felder = projektFelder(d).filter(feld => pfade.includes(feld.pfad.join('.'))); | ||
| expect(felder).toHaveLength(pfade.length); | ||
| const ersatz = '"Ole-Jan" \\ * _ # = -\nOle'; | ||
| for (const feld of felder) { | ||
| expect(ersetzeText(feld.text, 'Hafen', ersatz, {}, feld.markdown)).toBe(`**${ersatz}**`); | ||
| expect(sucheText(feld.text, '**Hafen**', {}, feld.markdown)).toHaveLength(1); | ||
| expect(feld.markdown).toBe(false); | ||
| } | ||
| }); | ||
| it('überspringt vollständige interne Verweise und behält Klartextoffsets außerhalb bei', () => { | ||
| const verweise = ['figur', 'ort', 'kapitel', 'plan', 'idee', 'gruppe'] | ||
| .map(typ => `[[${typ}:paul|Paul]]`).join(' '); | ||
| const text = `Paul ${verweise} Paul [[figur:paul]]`; | ||
| expect(sucheText(text, 'Paul')).toMatchObject([ | ||
| { start: 0, ende: 4 }, { start: 5 + verweise.length + 1, ende: 5 + verweise.length + 5 }, | ||
| ]); | ||
| expect(ersetzeText(text, 'Paul', 'Ole')).toBe(`Ole ${verweise} Ole [[figur:paul]]`); | ||
| expect(sucheText(text, 'Paul', { caseSensitiv: true, ganzesWort: true })).toHaveLength(2); | ||
| expect(sucheText(text, 'figur')).toEqual([]); | ||
| expect(sucheText(text, '|Paul]] Paul')).toEqual([]); | ||
| expect(sucheText(text, 'Paul [[figur')).toEqual([]); | ||
| expect(sucheText('[[unbekannt:paul|Paul]]', 'Paul')).toHaveLength(2); | ||
| }); | ||
| it('ersetzt sichtbaren Leerraum und maskierte Zeichen ohne Markdown zu beschädigen', () => { | ||
| expect(klartext(ersetzeText('Hafen\nHafen', 'Hafen Hafen', 'Ole', {}, true))).toBe('Ole'); | ||
| expect(klartext(ersetzeText('Hafen', 'Hafen', '===', {}, true))).toBe('==='); | ||
| expect(klartext(ersetzeText('Hafen\n\nHafen', 'Hafen', 'Ole\n===', {}, true))).toBe('Ole\n===\n\nOle\n==='); | ||
| expect(klartext(ersetzeText('\\*Hafen\\*', '*Hafen*', 'Ole', {}, true))).toBe('Ole'); | ||
| expect(ersetzeText('**Hafen** Hafen', 'Hafen', 'Ole', {}, true)).toBe('**Ole** Ole'); | ||
| }); | ||
| it('durchsucht auch Zeitnotizen, verwaiste Kapitelpläne und bearbeitete Wendungen', async () => { | ||
| const d = await ladeProjekt(demoFs()); | ||
| d.plaene.flut!.zeit = { sicherheit: 'fest', notiz: 'Hafen' }; | ||
| d.plaene['plan-ohne-manuskript'] = { ...d.plaene.flut!, id: 'plan-ohne-manuskript', notiz: 'Hafen' }; | ||
| d.entscheidungen.zusammenfassungen = [{ kapitel: 'flut', text: 'Ole', basisHash: '', wendung: 'Hafen' }]; | ||
| d.recherche!.eintraege = [{ id: 'notiz', art: 'notiz', titel: 'Ole', text: '**Hafen**', verweise: [], erstellt: '' }]; | ||
| const treffer = sucheProjekt(d, 'Hafen'); | ||
| expect(treffer.map(x => x.feld.pfad.join('.'))).toEqual(expect.arrayContaining([ | ||
| 'plaene.flut.zeit.notiz', 'plaene.plan-ohne-manuskript.notiz', 'entscheidungen.zusammenfassungen.0.wendung', 'recherche.eintraege.0.text', | ||
| ])); | ||
| expect(treffer.find(x => x.feld.pfad.join('.') === 'recherche.eintraege.0.text')!.feld).toMatchObject({ markdown: false, bereich: 'notizen' }); | ||
| }); | ||
| it('findet mehrere Treffer mit Kontext, Unicode-Wortgrenzen und Case-Option', () => { | ||
| expect(sucheText('Ole Oleander OLE ÄOle Olé Ole2', 'Ole', { ganzesWort: true })).toHaveLength(2); | ||
| expect(sucheText('Hafen hafen', 'hafen', { caseSensitiv: true })).toMatchObject([{ start: 6, ende: 11, kontext: 'Hafen hafen' }]); | ||
| expect(sucheText('Hafen Hafen', 'hafen')).toHaveLength(2); | ||
| expect(sucheText('Hafen', '')).toEqual([]); | ||
| expect(sucheText('Hafen', '.*')).toEqual([]); | ||
| }); | ||
| it('sucht Absatz für Absatz und erhält Markdown und wörtlichen Ersatz', () => { | ||
| const md = '# Hafen\n\nAm **Hafen** und *Hafen*.\n\nHafen'; | ||
| expect(sucheText(md, 'hafen', {}, true)).toHaveLength(4); | ||
| expect(sucheText(md, 'Hafen Am', {}, true)).toEqual([]); | ||
| expect(ersetzeText(md, 'Hafen', '"Ole"', {}, true)).toBe('# "Ole"\n\nAm **"Ole"** und *"Ole"*.\n\n"Ole"'); | ||
| expect(ersetzeText('Hafen Hafen', 'Hafen', '$&')).toBe('$& $&'); | ||
| expect(ersetzeText('**Hafen**', 'Hafen', '*Ole*', {}, true)).toBe('**\\*Ole\\***'); | ||
| expect(klartext(ersetzeText('**Ha**fen', 'Hafen', 'Ole', {}, true))).toBe('Ole'); | ||
| expect(klartext(ersetzeText('Ha**fen**', 'Hafen', 'Ole', {}, true))).toBe('Ole'); | ||
| expect(klartext(ersetzeText('Hafen', 'Hafen', '---', {}, true))).toBe('---'); | ||
| expect(klartext(ersetzeText('Hafen\n\nHafen', 'Hafen', '"Ole"\\*', {}, true))).toBe('"Ole"\\*\n\n"Ole"\\*'); | ||
| expect(ersetzeText('Hafen\nHafen', 'Hafen Hafen', 'Ole', {}, true)).toBe('Ole'); | ||
| }); | ||
| it('findet Manuskript in Buchreihenfolge und sämtliche Plan-/Privatfelder ohne IDs oder Anker', async () => { | ||
| const d = await ladeProjekt(demoFs()); | ||
| d.figuren.paul!.plan['Hafen'] = 'Hafen'; | ||
| d.figuren.paul!.plan['anker'] = 'Hafen'; | ||
| d.figuren.paul!.notizen = 'Hafen'; | ||
| d.orte.lindenhof!.notizen = 'Hafen'; | ||
| d.plaene.flut!.funktion = 'Hafen'; | ||
| d.plaene.flut!.szenen = [{ id: 'Hafen', kapitel: 'flut', index: 0, titel: 'Hafen', figuren: [], notiz: 'Hafen' }]; | ||
| const felder = projektFelder(d); | ||
| expect(felder.filter(x => x.text === 'Hafen').map(x => x.pfad.join('.'))).toEqual(expect.arrayContaining([ | ||
| 'figuren.paul.plan.Hafen', 'figuren.paul.plan.anker', 'figuren.paul.notizen', 'orte.lindenhof.notizen', 'plaene.flut.funktion', 'plaene.flut.szenen.0.notiz', | ||
| ])); | ||
| expect(felder.some(x => (x.pfad.includes('anker') && !x.pfad.includes('plan')) || x.pfad.at(-1) === 'id')).toBe(false); | ||
| expect(felder.some(x => x.pfad.at(-1) === 'art' && x.pfad[0] === 'recherche')).toBe(false); | ||
| const treffer = sucheProjekt(d, 'ich').filter(x => x.feld.bereich === 'manuskript'); | ||
| const nummern = treffer.map(x => x.feld.nummer!); | ||
| expect(nummern).toEqual([...nummern].sort((a, b) => a - b)); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.