diff --git a/core/suche/importe.test.ts b/core/suche/importe.test.ts new file mode 100644 index 0000000..5ff16a3 --- /dev/null +++ b/core/suche/importe.test.ts @@ -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/); + }); +}); diff --git a/core/suche/index.ts b/core/suche/index.ts new file mode 100644 index 0000000..2dd6cbc --- /dev/null +++ b/core/suche/index.ts @@ -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; + return { ...record, [key!]: setze(record[String(key)], rest) }; + }; + return setze(d, pfad) as ProjektDaten; +} diff --git a/core/suche/suche.test.ts b/core/suche/suche.test.ts new file mode 100644 index 0000000..420acaa --- /dev/null +++ b/core/suche/suche.test.ts @@ -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)); + }); +}); diff --git a/core/versionen/index.ts b/core/versionen/index.ts index 0e99731..d61f3ed 100644 --- a/core/versionen/index.ts +++ b/core/versionen/index.ts @@ -4,7 +4,7 @@ import type { ProjektDateisystem, ProjektLeser } from '../projekt/fs'; /** extern: vor dem Überschreiben einer Fassung, die außerhalb der App geändert wurde (immer). */ /** 'benannt': für eine benannte Fassung (G5) – immer ein Schnappschuss. */ -export type SchnappschussAnlass = 'kapitelwechsel' | 'intervall' | 'beenden' | 'wiederherstellen' | 'entfernen' | 'extern' | 'benannt'; +export type SchnappschussAnlass = 'kapitelwechsel' | 'intervall' | 'beenden' | 'wiederherstellen' | 'entfernen' | 'extern' | 'benannt' | 'ersetzen'; export interface Version { /** Originalpfad, z. B. manuskript/grundriss.md */ @@ -92,6 +92,7 @@ export function sollSchnappschuss( switch (anlass) { case 'extern': case 'benannt': + case 'ersetzen': return true; case 'intervall': return geaendertSeitLetztem && (letzter === null || jetzt.getTime() - letzter.getTime() >= ZEHN_MINUTEN); @@ -175,4 +176,3 @@ export async function aufbewahrungAnwenden(fs: ProjektDateisystem, jetzt: Date, } return geloescht; } - diff --git a/docs/architektur.md b/docs/architektur.md index 73445d7..3e3c182 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -108,9 +108,23 @@ Token-Namen aus `src/theme/tokens.css` ohne `--`. Gesamtwortzahl des Buchs verglichen; der Tag ist das lokale Kalenderdatum des Macs. Zunahme zählt als „geschrieben“, Abnahme zieht nichts ab. Ein in einem Speichervorgang eingefügter Block ab 25 Wörtern wird zuerst mit den in den 10 Minuten davor gelöschten Wörtern verrechnet (Verschieben zählt nicht doppelt). Laden, Änderungen von -außen, Import, Wiederherstellen und Rückimport führen nur den `stand` nach. Geschrieben wird spätestens alle 15 s, +außen, Import, Wiederherstellen, Suchen/Ersetzen und Rückimport führen nur den `stand` nach. Geschrieben wird spätestens alle 15 s, beim Beenden und beim Projektwechsel. +**Suchen und Ersetzen** (`core/suche`, Issue #10): wörtliche Suche je Absatz, standardmäßig ohne +Groß-/Kleinschreibung und ohne Ganzwort-Option; Unicode-Buchstaben, Zahlen und kombinierende Zeichen +bilden Wortgrenzen. Keine regulären Ausdrücke. Manuskript in Buchreihenfolge, Plan-Textfelder und sämtliche +Notizen (auch private) werden lokal durchsucht; IDs, Verweise, Enumwerte, Textanker, KI-Ablagen und Fassungen +bleiben ausgenommen, einschließlich vollständiger `[[typ:id|Anzeige]]`-Spannen in Klartextfeldern. +Plan- und Notizfelder werden als Klartext behandelt; Ersatztext wird dort unverändert gespeichert. +Im Manuskript bleibt Markdown-Auszeichnung erhalten, Ersatztext wird als wörtlicher Text eingefügt +(Markdown-Sonderzeichen werden nur dort maskiert), ohne Typografie-Regeln. Vor jeder Kapitel-Ersetzung entsteht +ein neuer Schnappschuss (`SchnappschussAnlass: ersetzen`, immer); misslingt er, bleibt das Kapitel unverändert. +Alle ersetzen im Projekt fragt nach. App-Undo hält nur die letzte Ersetzen-Aktion für Plan/Notizen im Speicher, +verfällt beim Projektwechsel/Neuladen und überschreibt keine zwischenzeitlich bearbeiteten Textfelder. +Manuskript kann über den Versionsverlauf zurückgeholt werden; im offenen Editor bleibt Ersetzen eine +Undo-Transaktion. Textanker werden nach Ersetzen neu gesucht, nicht anhand alter Positionen zugeordnet. + **LeserStand** (G6): Ältere Stände legten Vermutungen, Spannung und Plan-Abgleich als `LeserFrage` mit Präfix in der `id` ab (`vermutung|…`, `spannung|…`, `plan|status|kapitel|…`); `ausLeserStand` liest beides, eigene Felder haben Vorrang, neu gespeichert wird nur die Form mit eigenen Feldern. Jede Simulation bleibt erhalten (mehrere Stände je @@ -131,6 +145,7 @@ Jedes Verzeichnis hat genau einen Besitzer. Fremde Dateien nur lesen. Braucht ei | `core/projekt/` | Parse/Serialize, laden/speichern, MemoryFs, Schnappschuss-Ablage | Fundament | | `core/text/` | Normalisierung, Klartext aus Markdown, Absätze, Hash, Wortzahl, Textanker (finden/erzeugen) | Kernlogik | | `core/typografie/` | reine Funktionen für deutsche Typografie (vom Editor als Input-Rules genutzt) | Kernlogik | +| `core/suche/` | lokale Volltextsuche und wörtliches Ersetzen in Manuskript, Plan und Notizen | Kernlogik | | `core/erkennung/` | exakte Erkennung von Figuren/Orten (E5), Schreibweisen-Varianten | Kernlogik | | `core/struktur/` | lokale Struktur-Hinweise (E10) | Kernlogik | | `core/versionen/` | Schnappschuss-Politik, Aufbewahrung, Zeilen-Diff | Kernlogik | @@ -143,6 +158,7 @@ Jedes Verzeichnis hat genau einen Besitzer. Fremde Dateien nur lesen. Braucht ei | `src/backend/` | Backend-Auswahl, MemoryFs-Demo | Fundament | | `src/backend/tauri/` | TauriFs, Schlüsselbund, KI-Transport, SQLite-Index | Tauri | | `src/features/schreiben/` | Editor, Kapitelliste, Plan-Panel, Anmerkungen, Versionsverlauf, Fokus | Editor | +| `src/features/suche/`, `src/state/slices/suche.ts` | Kapitel-/Projektsuche, Sicherheitsnetz und letztes App-Undo | Editor | | `src/features/akte/`, `kapitel/`, `figuren/`, `orte/` | Planungs-Ansichten | Planung | | `src/features/beziehungen/`, `hinweise/`, `gruppen/` | Netz, Hinweise, Antagonisten | Netz & Hinweise | | `src/features/importexport/` | Import-Vorschau, Export-Dialog | Import/Export | diff --git a/docs/handbuch.md b/docs/handbuch.md index 59c7b4a..ea294c4 100644 --- a/docs/handbuch.md +++ b/docs/handbuch.md @@ -177,11 +177,31 @@ aktive Werkzeug sichtbar). Alle Befehle finden sich auch in der Schnellsuche ( ## Tastenkürzel +### Suchen und Ersetzen + +Im Schreiben-Bereich öffnet ⌘F die Suchleiste für das aktuelle Kapitel. Alle Treffer werden markiert; +Enter / Umschalt+Enter und die Vor-/Zurück-Knöpfe springen durch die Treffer. „Ganzes Wort“ und +„Groß- und Kleinschreibung beachten“ verfeinern die wörtliche Suche (keine regulären Ausdrücke). +„Ersetzen“ ändert den aktiven Treffer, „Alle ersetzen“ alle Treffer des Kapitels. + +⌥⌘F öffnet die Projektsuche: Manuskript in Buchreihenfolge, Plan und Notizen (einschließlich privater) +erscheinen getrennt. Ein Manuskripttreffer öffnet das Kapitel an der Stelle; einen Plan-/Notiztreffer +anklicken, um ihn für „Ersetzen“ auszuwählen. „Alle ersetzen“ fragt vor der Projektaktion nach. +Vor jedem betroffenen Kapitel wird ein Schnappschuss angelegt. Misslingt die Sicherung, bleibt das +Kapitel unverändert und wird in der Fehlermeldung genannt. Rückkehr über den Versionsverlauf, +im offenen Kapitel auch über Editor-Undo. Die letzte Ersetzen-Aktion für Plan und Notizen lässt sich +im Suchdialog oder über Bearbeiten → „Ersetzen in Plan und Notizen widerrufen“ als Ganzes zurücknehmen. +Dieses App-Undo gilt nur bis zum Neuladen/Projektwechsel und überschreibt keine späteren Feldänderungen. +Private Treffer bleiben lokal; die Suche nutzt keine KI. Ersetzen zählt nicht als geschrieben. + | Kürzel | Wirkung | | --- | --- | | ⌘1 … ⌘7 | Schreiben, Kapitel-Überblick, Akte planen, Figuren, Beziehungen, Orte, Hinweise | | ⌘8 · ⌘9 · ⌘0 | Zeitleiste · Fäden · Recherche | | ⌘K | Schnellsuche (Figuren, Orte, Kapitel, Befehle) | +| ⌘F | Suchen und Ersetzen im aktuellen Kapitel (Schreiben) | +| ⌥⌘F | Suchen und Ersetzen im Projekt | +| ↩ · ⇧↩ | nächster · vorheriger Suchtreffer (Suchleiste) | | ⌘O | Projekt öffnen | | ⌘E | Exportieren | | ⌘, | Einstellungen | diff --git a/docs/issue-10-handover.md b/docs/issue-10-handover.md new file mode 100644 index 0000000..c75cd1e --- /dev/null +++ b/docs/issue-10-handover.md @@ -0,0 +1,89 @@ +# Issue #10 – Suchen und Ersetzen + +## Grundlage und Lieferstand + +Basis: `f20de68326a41a8c862ab88f08d4ab7a815f3059`, identisch mit dem lokalen +`origin/main`. Der abgebrochene Versuch wurde anhand von `git status` und `git diff` +fortgesetzt. Keine fremden PR-Stände verwendet. Änderungen liegen uncommittet im Workspace. + +GitHub-Issue-Thread und offene PRs konnten nicht abgerufen werden: Die Umgebung sperrt +`gh`. Verbindliche Grundlage ist deshalb die vollständige strukturierte Issue-Beschreibung +im Auftrag. Kommentare und mögliche zusätzliche Entscheidungen müssen vor der endgültigen +Abnahme geprüft werden; eine Abhängigkeit von offenen PRs ist nicht nachgewiesen. +Die ältere Beschreibung schließt Plan/Notizen aus; die neuere strukturierte Fassung und +AC-9 nehmen sie ausdrücklich auf und haben für diese Umsetzung Vorrang. + +## Abnahmekriterien und Nachweise + +| Kriterium | Umsetzung / automatischer Nachweis | +| --- | --- | +| AC-1 | ⌘F im Schreiben, alle Treffer dekoriert, Zähler, Enter/Shift+Enter und Navigation; `src/features/suche/suche.test.tsx` | +| AC-2 | ⌥⌘F, getrennte Bereiche, Manuskript in Buchreihenfolge mit Nummer/Titel/Kontext, Klick springt über Textanker zum Editor; Core-, UI- und `src/app/app.test.tsx`-Tests | +| AC-3 | Unicode-Wortgrenzen und Case-Option; `core/suche/suche.test.ts` | +| AC-4 | Neuer Schnappschuss je betroffenem Kapitel vor Persistenz, immer auch bei identischem bisherigen Stand; unbetroffene Kapitel bleiben ungeschrieben; `src/state/slices/suche.test.ts` | +| AC-5 | Nur `stand`, vorhandenes `geschrieben` unverändert und erster Tag mit `geschrieben: 0`; Persistenztest gegen `statistik.json`, Zählregel in Architektur ergänzt | +| AC-6 | Wörtlicher Ersatz ohne Typografie, einschließlich Anführungszeichen, Dollarzeichen, Backslash und Markdown-Sonderzeichen; Core-/Editor-Tests | +| AC-7 | DE-/EN-Leerzustände, Ersetzen bei leerer Suche/ohne Treffer deaktiviert; UI-Tests | +| AC-8 | Fehlgeschlagener Schnappschuss lässt das betreffende Kapitel unverändert, Meldung nennt den Titel, übrige Kapitel werden bearbeitet; Store-Tests | +| AC-9 | Planfelder und alle lokalen Notizen einschließlich Figur/Ort/Wahrheit/Zeitnotizen/Recherche-Notizen; nach Bereichen gruppiert, Rückfrage und App-Undo als ganze Aktion getestet | + +Zusätzlich: Editor-Undo, neu gesuchte/verwaiste Anker, ungespeicherter Editor, +gleichzeitig gestartete Aktionen, frei benannte Planfelder und lokale Importgrenzen getestet. +App-Undo hält die letzte Plan-/Notizaktion nur im Speicher; Projektwechsel/Neuladen +verwerfen sie. Bei späteren Änderungen an betroffenen Feldern verweigert Undo die +gesamte Rücknahme, damit nichts überschrieben wird. Manuskript wird über Editor-Undo +oder Versionsverlauf zurückgenommen. + +## Testgetrieben ergänzter Rest + +Vor den Korrekturen nachweislich rot: + +- Markdown-Ersatz mit mehrzeiligem `===` und maskierten Satzzeichen; +- fehlende Kapitelpläne außerhalb der Buchliste / bearbeitete Zusammenfassungswendung; +- gleichzeitige Ersetzen-Aktionen verlieren das App-Undo; +- frei benanntes Figur-Planfeld `anker` wird irrtümlich ausgelassen. + +### Nachbesserung der zwei Review-Befunde + +Verbindlich für diesen Nachlauf: AC-6 (wörtlicher Ersatz), AC-9 (private Notizen +unbeschädigt, gesamte Aktion rücknehmbar) und der dokumentierte Ausschluss von Verweisen. +Plan- und Notizfelder einschließlich Recherche-Notizen sind Klartext, wie ihre Darstellung +durch `VerweisText`; nur Manuskriptfelder werden als Markdown behandelt. +Die Klartextsuche überspringt vollständige gültige `[[typ:id|Anzeige]]`-Spannen nach +dem Muster von `verweiseZerlegen`, auch Treffer, die eine solche Spanne überlappen. +IDs und gespeicherte Anzeigen bleiben unverändert; Treffer außerhalb behalten ihre Quelloffsets. + +Vor dem Fix: gezielter Lauf von `core/suche/suche.test.ts` und +`src/state/slices/suche.test.ts` mit **5 fehlgeschlagenen, 13 bestandenen Tests**. +Die vier neuen Regressionstests reproduzierten zusätzliche Backslashes in Notizen +und beschädigte Verweisziele nach Persistenz/Neuladen; die fünfte rote Prüfung war +die korrigierte bisherige Markdown-Erwartung für Recherche-Notizen. +Nach dem Fix: **32 Tests in 5 Dateien bestanden** (Core-Suche, Store-Suche, +Such-UI und Verweisparser), einschließlich Persistenz/Neuladen und App-Undo. + +Nach den Fixes grün. Die bereits vorhandenen Tests des abgebrochenen Versuchs wurden +übernommen; ihr ursprünglicher Rotlauf ist in dieser Sitzung nicht nachweisbar. +App-Tests warten jetzt explizit auf die asynchrone Initialisierung bzw. den +lazy geladenen Editor; der mehrstufige App-Integrationstest hat ein Zeitbudget von +15 Sekunden, damit er auch bei parallelem Build nicht nach fünf Sekunden abbricht. + +## Qualitätsprüfungen + +- `npm test`: **1.247 Tests in 123 Dateien bestanden** (nach der Review-Nachbesserung). +- `npm run typecheck` und `npm run build`: bestanden (lokaler Frontend-Build). +- `git diff --check`: bestanden. +- `npm run sperrliste`: Exit 0, **keine Inhaltsprüfung**, weil die lokale + `~/.config/plotwerk/sperrliste.txt` auf dem Server fehlt. +- Rust/Tauri gemäß Auftrag auf Linux nicht gebaut. Die vorhandene macOS-CI führt + `cargo fmt --check`, Clippy und `cargo test --workspace` aus; noch ausstehend. + +## Noch extern abzunehmen + +1. Vollständige Issue-Kommentare und offene PRs auf zusätzliche Kriterien prüfen. +2. macOS-CI für die DE-/EN-Menüänderungen prüfen. +3. Auf dem Mac: ⌘F, ⌥⌘F, ⌘⇧F und Recherche-⌘F; native Menüs in DE/EN; + Alle ersetzen im Demo-Projekt Hafenlichter und Rückkehr über Versionsverlauf; + Plan-/Privatnotizen per App-Undo zurückholen. +4. Sichtbare UI mit den Design-Screens vergleichen und Screenshot für den Review + erstellen. Für die neue Suchleiste gibt es keinen eigenen Design-Screen; + sie nutzt die vorhandenen UI-Bausteine, Abstände und Farb-Tokens. diff --git a/src-tauri/src/menue.rs b/src-tauri/src/menue.rs index d60dcb6..47103e4 100644 --- a/src-tauri/src/menue.rs +++ b/src-tauri/src/menue.rs @@ -172,6 +172,31 @@ pub fn erstellen(app: &AppHandle) -> tauri::Result> { .paste_with_text(t("Einsetzen", "Paste")) .select_all_with_text(t("Alles auswählen", "Select All")) .separator() + .item(&befehl( + app, + "suchen", + t("Suchen …", "Find…"), + Some("CmdOrCtrl+F"), + )?) + .item(&befehl(app, "ersetzen", t("Ersetzen …", "Replace…"), None)?) + .item(&befehl( + app, + "projektsuche", + t( + "Im Projekt suchen und ersetzen …", + "Find and Replace in Project…", + ), + Some("CmdOrCtrl+Alt+F"), + )?) + .item(&befehl( + app, + "ersetzen-undo", + t( + "Ersetzen in Plan und Notizen widerrufen", + "Undo Replacement in Plan and Notes", + ), + None, + )?) .item(&befehl( app, "schnellsuche", diff --git a/src/app/app.test.tsx b/src/app/app.test.tsx index 4281a29..ec4da6b 100644 --- a/src/app/app.test.tsx +++ b/src/app/app.test.tsx @@ -1,9 +1,10 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { ladeDemoProjekt } from '../../fixtures/demoProjekt'; +import { demoFs, ladeDemoProjekt } from '../../fixtures/demoProjekt'; import { useStore } from '../state/store'; import { App } from './App'; import { befehlFuerTaste } from './befehle'; +import { useTastenkuerzel } from './tastenkuerzel'; import { suche } from './suche'; import { BEFEHL_EREIGNIS } from './befehle'; import { initialisiereDarstellung, setzeDarstellung, useDarstellungsStore } from '../state/darstellung'; @@ -13,6 +14,28 @@ const taste = (key: string, extra: Partial = {}) => befehlFuerTaste({ metaKey: true, ctrlKey: false, altKey: false, shiftKey: false, key, code: '', ...extra }); describe('Tastenkürzel', () => { + it('öffnet ⌥⌘F als Dialog und lässt ⌘⇧F sowie ⌘K unverändert', async () => { + await useStore.getState().initialisieren(); + await useStore.getState().projektLaden(demoFs()); + function Kuerzel() { useTastenkuerzel(); return null; } + render(); + await act(async () => { fireEvent.keyDown(window, { key: 'ƒ', code: 'KeyF', metaKey: true, altKey: true }); }); + expect(useStore.getState().dialog?.id).toBe('projektsuche'); + expect(useStore.getState().fokusModus).toBe(false); + act(() => useStore.getState().schliesseDialog()); + await act(async () => { fireEvent.keyDown(window, { key: 'F', metaKey: true, shiftKey: true }); }); + expect(useStore.getState().fokusModus).toBe(true); + await act(async () => { fireEvent.keyDown(window, { key: 'k', metaKey: true }); }); + expect(useStore.getState().dialog?.id).toBe('schnellsuche'); + act(() => { useStore.getState().schliesseDialog(); useStore.getState().umschalteFokus(); }); + }); + it('⌥⌘F ist Projektsuche, ⌘F Kapitelsuche, ⌘⇧F bleibt Fokus', () => { + expect(taste('f', { altKey: true })).toBe('projektsuche'); + expect(befehlFuerTaste({ metaKey: true, ctrlKey: false, altKey: true, shiftKey: false, key: 'ƒ', code: 'KeyF' })).toBe('projektsuche'); + expect(taste('f')).toBe('suchen'); + expect(taste('F', { shiftKey: true })).toBe('fokus'); + expect(taste('f', { altKey: true, shiftKey: true })).toBeNull(); + }); it('⌘+/⌘− unterstützen deutsche und US-Tastaturen ohne Kollisionen', () => { expect(taste('+')).toBe('ui-vergroessern'); expect(taste('+', { shiftKey: true, code: 'Equal' })).toBe('ui-vergroessern'); @@ -127,5 +150,5 @@ describe('App im Browser-Demo', () => { fireEvent.keyDown(window, { key: 'F', metaKey: true, shiftKey: true }); }); expect(screen.queryByRole('navigation', { name: 'Bereiche' })).toBeNull(); - }); + }, 15_000); }); diff --git a/src/app/befehle.ts b/src/app/befehle.ts index 8ac3aa7..40fed0b 100644 --- a/src/app/befehle.ts +++ b/src/app/befehle.ts @@ -11,6 +11,10 @@ import { setzeDarstellung, skaliereOberflaeche } from '../state/darstellung'; export type Befehl = | Bereich | 'schnellsuche' + | 'suchen' + | 'ersetzen' + | 'projektsuche' + | 'ersetzen-undo' | 'fokus' | 'ui-vergroessern' | 'ui-verkleinern' @@ -55,6 +59,19 @@ export async function befehlAusfuehren(befehl: Befehl): Promise { } const sichern = () => s.editorSichern().catch((e: unknown) => console.error('Sichern vor dem Befehl fehlgeschlagen:', e)); switch (befehl) { + case 'suchen': + case 'ersetzen': + if (s.daten && s.bereich === 'schreiben') s.volltext.oeffneKapitel(true); + else if (s.bereich === 'recherche') window.dispatchEvent(new CustomEvent('plotwerk:recherche-suche')); + break; + case 'projektsuche': + if (!s.daten) break; + await s.editorSichern(); + s.oeffneDialog('projektsuche'); + break; + case 'ersetzen-undo': + await s.volltext.undo(); + break; case 'ui-vergroessern': await skaliereOberflaeche(1); break; @@ -173,7 +190,8 @@ export async function befehlAusfuehren(befehl: Befehl): Promise { /** Befehl zu einem Tastendruck (nur ⌘, macOS). */ export function befehlFuerTaste(e: Pick): Befehl | null { - if (!e.metaKey || e.ctrlKey || e.altKey) return null; + if (!e.metaKey || e.ctrlKey) return null; + if (e.altKey) return !e.shiftKey && (e.code === 'KeyF' || e.key.toLowerCase() === 'f') ? 'projektsuche' : null; const taste = e.key.toLowerCase(); // „+“ braucht auf US-Tastaturen Shift; ⌘= ist dort ebenfalls üblich. if (taste === '+' || (!e.shiftKey && taste === '=')) return 'ui-vergroessern'; @@ -182,6 +200,7 @@ export function befehlFuerTaste(e: Pick b.taste && (b.taste === taste || e.code === `Digit${b.taste}`)); if (bereich) return bereich.id; if (taste === 'k') return 'schnellsuche'; + if (taste === 'f') return 'suchen'; if (taste === 'e') return 'export'; if (taste === ',') return 'einstellungen'; if (taste === 'o') return 'projekt-oeffnen'; diff --git a/src/app/dialoge.tsx b/src/app/dialoge.tsx index 16b6132..67f3054 100644 --- a/src/app/dialoge.tsx +++ b/src/app/dialoge.tsx @@ -15,6 +15,7 @@ export const DIALOGE: Record = { 'ki-pruefen': lazy(() => import('../features/ki/PruefDialog')), 'ki-einstellungen': lazy(() => import('../features/ki/EinstellungenDialog')), schnellsuche: lazy(() => import('./Schnellsuche')), + projektsuche: lazy(() => import('../features/suche/Projektsuche')), funktionen: lazy(() => import('../features/funktionen/FunktionenDialog')), // Werkzeuge G1–G8 'zeit-vorschlaege': lazy(() => import('../features/zeitleiste/ZeitVorschlagDialog')), diff --git a/src/app/tastenkuerzel.ts b/src/app/tastenkuerzel.ts index 03e30c9..b3bddd5 100644 --- a/src/app/tastenkuerzel.ts +++ b/src/app/tastenkuerzel.ts @@ -10,6 +10,7 @@ export function useTastenkuerzel(): void { if (e.defaultPrevented || e.repeat) return; const befehl = befehlFuerTaste(e); if (!befehl) return; + if (befehl === 'suchen' && useStore.getState().bereich !== 'schreiben') return; // solange ein Dialog offen ist, wechseln Tastenkürzel nicht den Bereich dahinter if (useStore.getState().dialog && BEREICHE.some((b) => b.id === befehl)) return; e.preventDefault(); diff --git a/src/features/funktionen/funktionen.test.tsx b/src/features/funktionen/funktionen.test.tsx index dd760a2..78f99e4 100644 --- a/src/features/funktionen/funktionen.test.tsx +++ b/src/features/funktionen/funktionen.test.tsx @@ -120,6 +120,7 @@ describe('Laden', () => { describe('Ausgeblendete Funktionen in der App', () => { it('fehlen in Seitenleiste, Titelleiste und Schnellsuche; ein Aufruf fragt nach und blendet ein', async () => { festesFunktionsProfil({ ...profilMitPreset(standardProfil(), 'einsteiger'), eingerichtet: true }); + await useStore.getState().initialisieren(); render(); const nav = await screen.findByRole('navigation', { name: 'Bereiche' }); fireEvent.click(within(nav).getByRole('button', { name: 'Werkzeuge' })); diff --git a/src/features/recherche/index.tsx b/src/features/recherche/index.tsx index 41db11e..c233eb6 100644 --- a/src/features/recherche/index.tsx +++ b/src/features/recherche/index.tsx @@ -731,6 +731,11 @@ export default function Ansicht() { // ⌘N, ⌘F und Einfügen, solange der Bereich sichtbar ist und kein Dialog offen ist useEffect(() => { + const sucheFokussieren = () => { + if (useStore.getState().dialog) return; + sucheRef.current?.focus(); + sucheRef.current?.select(); + }; const taste = (e: globalThis.KeyboardEvent) => { if (e.defaultPrevented || !e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || useStore.getState().dialog) return; const k = e.key.toLowerCase(); @@ -751,9 +756,11 @@ export default function Ansicht() { void uebergeben(e.clipboardData); }; window.addEventListener('keydown', taste); + window.addEventListener('plotwerk:recherche-suche', sucheFokussieren); window.addEventListener('paste', einfuegen); return () => { window.removeEventListener('keydown', taste); + window.removeEventListener('plotwerk:recherche-suche', sucheFokussieren); window.removeEventListener('paste', einfuegen); }; }, [neu, uebergeben]); diff --git a/src/features/recherche/recherche.test.tsx b/src/features/recherche/recherche.test.tsx index 27a08ec..4598351 100644 --- a/src/features/recherche/recherche.test.tsx +++ b/src/features/recherche/recherche.test.tsx @@ -4,6 +4,8 @@ import { demoFs } from '../../../fixtures/demoProjekt'; import type { DialogKomponentenProps } from '../../app/dialogTypen'; import { useStore } from '../../state/store'; import { BestaetigungHost } from '../../ui'; +import { useTastenkuerzel } from '../../app/tastenkuerzel'; +import { befehlAusfuehren } from '../../app/befehle'; import EintragDialog from './EintragDialog'; import Ansicht, { kontext } from './index'; import { RechercheLeiste } from './RechercheLeiste'; @@ -47,6 +49,18 @@ const optionen = () => within(screen.getByRole('listbox')).getAllByRole('option' const titelDer = (el: HTMLElement) => el.querySelector('.rc-kachel__titel, .rc-zeile__titel')?.textContent; describe('Bereich Recherche', () => { + it('⌘F und der native Suchen-Befehl fokussieren weiter nur die Recherche-Suche', async () => { + function Kuerzel() { useTastenkuerzel(); return null; } + render(<>); + const suche = screen.getByRole('searchbox', { name: 'Recherche durchsuchen' }); + fireEvent.keyDown(window, { key: 'f', metaKey: true }); + expect(suche).toHaveFocus(); + expect(useStore.getState().volltext.kapitelOffen).toBe(false); + suche.blur(); + await act(async () => { await befehlAusfuehren('suchen'); }); + expect(suche).toHaveFocus(); + expect(useStore.getState().dialog).toBeNull(); + }); it('zeigt die Einträge (neueste zuerst), filtert nach Art und Suche', async () => { render(); expect(screen.getByRole('heading', { name: 'Recherche' })).toBeInTheDocument(); diff --git a/src/features/schreiben/Editor.tsx b/src/features/schreiben/Editor.tsx index d4aa83b..514c33f 100644 --- a/src/features/schreiben/Editor.tsx +++ b/src/features/schreiben/Editor.tsx @@ -13,6 +13,7 @@ import { ankerErzeugen, ankerFinden, findeZitat, woerterZaehlen } from '@core/te import Placeholder from '@tiptap/extension-placeholder'; import type { Node as PMNode } from '@tiptap/pm/model'; import { EditorState, TextSelection } from '@tiptap/pm/state'; +import { closeHistory } from '@tiptap/pm/history'; import { EditorContent, useEditor, type Editor } from '@tiptap/react'; import { EditorWerkzeuge, editorErweiterungen } from './werkzeuge'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; @@ -47,6 +48,8 @@ import { basisErweiterungen } from './schema'; import { Statusleiste } from './Statusleiste'; import { Typografie } from './typografie'; import { fokusFiguren, notizFarbe } from './ziele'; +import { Kapitelsuche } from '../suche/Kapitelsuche'; +import { SuchDekorationen } from '../suche/dekorationen'; /** Cursor und Scrollstand je Kapitel (für die Rückkehr) */ const auswahlSpeicher = new Map(); @@ -123,6 +126,7 @@ export function SchreibEditor({ kapitelId }: { kapitelId: Id }) { /** Buchsprache für Rechtschreibprüfung und Silbentrennung des WebViews */ const buchLang = useBuchSprache(); const fokusModus = useStore((s) => s.fokusModus); + const sucheOffen = useStore((s) => s.volltext.kapitelOffen); const typewriter = useStore((s) => s.editorwerkzeuge.typewriter); const kapitelText = useStore((s) => s.daten?.kapitel[kapitelId]?.text ?? ''); const titel = useStore((s) => s.daten?.kapitel[kapitelId]?.titel ?? s.daten?.plaene[kapitelId]?.titel ?? ''); @@ -310,6 +314,7 @@ export function SchreibEditor({ kapitelId }: { kapitelId: Id }) { extensions: [ ...basisErweiterungen(), Typografie, + SuchDekorationen, Dekorationen.configure({ handler }), Placeholder.configure({ placeholder: () => texte().schreiben.editor.platzhalter }), ...editorErweiterungen(), @@ -418,10 +423,22 @@ export function SchreibEditor({ kapitelId }: { kapitelId: Id }) { } autosave.verwerfen(); setUngespeichert(false); + if (useStore.getState().volltext.editorErsetzungAbholen(kapitelId, kapitelText)) { + // Ersetzen bleibt eine Editor-Undo-Aktion. Anker werden neu gesucht, nie über alte + // Dekorationspositionen still einer anderen Textstelle zugeordnet. + const doc = markdownZuDoc(kapitelText, editor.schema); + geladen.current = { ...g, text: kapitelText, doc }; + setzeNotizen(editor.view, []); + editor.view.dispatch(closeHistory(editor.state.tr.replaceWith(0, editor.state.doc.content.size, doc.content))); + editor.view.dispatch(closeHistory(editor.state.tr)); + geladen.current.doc = editor.state.doc; + analysieren(); + return; + } if (vonDatei) meldungZeigen(texte().schreiben.editor.externNeuGeladen); } laden(editor, kapitelId, kapitelText, !vonAussen); - }, [editor, kapitelId, kapitelText, autosave, laden, meldungZeigen]); + }, [editor, kapitelId, kapitelText, autosave, laden, meldungZeigen, analysieren]); // Vor dem Kapitelwechsel (und beim Verlassen): sichern, Auswahl merken, Schnappschuss (E8) useEffect(() => { @@ -635,6 +652,7 @@ export function SchreibEditor({ kapitelId }: { kapitelId: Id }) { return (
+ {sucheOffen && editor && }
s.volltext); + const [suche, setSuche] = useState(''), [ersatz, setErsatz] = useState(''); + const [optionen, setOptionen] = useState({}); + const [doc, setDoc] = useState(editor.state.doc); + const [aktiv, setAktiv] = useState(0), [fehler, setFehler] = useState([]); + useEffect(() => { + const update = () => setDoc(editor.state.doc); + editor.on('transaction', update); + return () => { editor.off('transaction', update); setzeSuchtreffer(editor.view, [], 0); }; + }, [editor]); + const karte = useMemo(() => klartextKarte(doc), [doc]); + const treffer = useMemo(() => sucheText(karte.text, suche, optionen), [karte, suche, optionen]); + const index = Math.min(aktiv, Math.max(0, treffer.length - 1)); + useEffect(() => { + if (editor.isDestroyed) return; + setzeSuchtreffer(editor.view, treffer.map(x => bereichZuPositionen(karte, x.start, x.ende)), index); + }, [editor, karte, treffer, index]); + const weiter = (richtung: number) => { + const i = (index + richtung + treffer.length) % (treffer.length || 1); + setAktiv(i); + const x = treffer[i]; + if (!x) return; + const b = bereichZuPositionen(karte, x.start, x.ende); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(doc, b.from, b.to)).scrollIntoView()); + }; + const ersetzen = async (alle: boolean) => { + const x = treffer[index]; + if (!x) return; + const text = docZuMarkdown(doc); + const r = await aktion.ersetzen(suche, ersatz, optionen, alle ? { kapitel } : { kapitel, einzeln: { ...x, feld: { pfad: ['kapitel', kapitel, 'text'], text, titel: kapitel, kapitel, bereich: 'manuskript', markdown: true } } }); + setDoc(editor.state.doc); + setFehler(r.fehler); + }; + return
{ if (e.key === 'Escape') aktion.oeffneKapitel(false); }}> + { setSuche(s); setAktiv(0); }} setOptionen={o => { setOptionen(o); setAktiv(0); }} /> +
+ {!suche ? t.leer : !treffer.length ? t.keine : `${index + 1} / ${f(t.treffer, { n: treffer.length })}`} + + + + + +
+ {fehler.length > 0 &&
{fehler.join('\n')}
} +
; +} diff --git a/src/features/suche/Projektsuche.tsx b/src/features/suche/Projektsuche.tsx new file mode 100644 index 0000000..f2cfdda --- /dev/null +++ b/src/features/suche/Projektsuche.tsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from 'react'; +import { sucheProjekt, type ProjektTreffer, type SuchOptionen } from '@core/suche'; +import { ankerErzeugen, klartext } from '@core/text'; +import { f, useTexte } from '../../i18n'; +import { useStore } from '../../state/store'; +import { bestaetigen, Button, Dialog } from '../../ui'; +import type { DialogKomponentenProps } from '../../app/dialogTypen'; +import { Suchfelder } from './Suchfelder'; +import './suche.css'; + +export default function Projektsuche({ schliessen }: DialogKomponentenProps) { + const t = useTexte().suche; + const d = useStore(s => s.daten); + const aktion = useStore(s => s.volltext); + const [suche, setSuche] = useState(''), [ersatz, setErsatz] = useState(''); + const [optionen, setOptionen] = useState({}); + const [aktiv, setAktiv] = useState(0), [fehler, setFehler] = useState([]); + const treffer = useMemo(() => d ? sucheProjekt(d, suche, optionen) : [], [d, suche, optionen]); + const index = Math.min(aktiv, Math.max(0, treffer.length - 1)); + const weiter = (richtung: number) => setAktiv((index + richtung + treffer.length) % (treffer.length || 1)); + const ersetzen = async (alle: boolean) => { + if (!treffer.length) return; + if (alle && !await bestaetigen(f(t.rueckfrage, { n: treffer.length, kapitel: new Set(treffer.filter(x => x.feld.bereich === 'manuskript').map(x => x.feld.kapitel)).size }), { titel: t.bestaetigen, knopf: t.alle })) return; + const r = await aktion.ersetzen(suche, ersatz, optionen, alle ? {} : { einzeln: treffer[index] }); + setFehler(r.fehler); + }; + const oeffnen = (x: ProjektTreffer, i: number) => { + setAktiv(i); + if (x.feld.bereich !== 'manuskript') return; + useStore.getState().markiere({ kapitel: x.feld.kapitel!, anker: ankerErzeugen(x.feld.kapitel!, klartext(x.feld.text), x.start, x.ende) }); + schliessen(); + }; + return +
+ { setSuche(s); setAktiv(0); }} /> +
+ {!suche ? t.leer : !treffer.length ? t.keine : f(t.treffer, { n: treffer.length })} + + + +
+ {fehler.length > 0 &&
{fehler.map((x, i) =>

{x}

)}
} +
+ {(['manuskript', 'plan', 'notizen'] as const).map(bereich =>
+

{t.bereiche[bereich]}

+ {treffer.map((x, i) => x.feld.bereich === bereich && )} +
)} +
+
+
; +} diff --git a/src/features/suche/Suchfelder.tsx b/src/features/suche/Suchfelder.tsx new file mode 100644 index 0000000..7f2aed5 --- /dev/null +++ b/src/features/suche/Suchfelder.tsx @@ -0,0 +1,22 @@ +import type { SuchOptionen } from '@core/suche'; +import { useTexte } from '../../i18n'; +import { Checkbox, Feld, Textfeld } from '../../ui'; + +export function Suchfelder({ suche, ersatz, optionen, setSuche, setErsatz, setOptionen, weiter }: { + suche: string; ersatz: string; optionen: SuchOptionen; + setSuche(s: string): void; setErsatz(s: string): void; setOptionen(o: SuchOptionen): void; weiter(richtung: number): void; +}) { + const t = useTexte().suche; + return <> +
+ {id => setSuche(e.target.value)} onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); weiter(e.shiftKey ? -1 : 1); } + }} />} + {id => setErsatz(e.target.value)} />} +
+
+ setOptionen({ ...optionen, ganzesWort: e.target.checked })}>{t.wort} + setOptionen({ ...optionen, caseSensitiv: e.target.checked })}>{t.case} +
+ ; +} diff --git a/src/features/suche/dekorationen.ts b/src/features/suche/dekorationen.ts new file mode 100644 index 0000000..b450407 --- /dev/null +++ b/src/features/suche/dekorationen.ts @@ -0,0 +1,16 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; +const key = new PluginKey('pw-volltext'); +export const SuchDekorationen = Extension.create({ + name: 'volltext', + addProseMirrorPlugins() { + return [new Plugin({ key, state: { + init: () => DecorationSet.empty, + apply: (tr, alt) => tr.getMeta(key) ? DecorationSet.create(tr.doc, tr.getMeta(key)) : alt.map(tr.mapping, tr.doc), + }, props: { decorations: state => key.getState(state) } })]; + }, +}); +export function setzeSuchtreffer(view: EditorView, stellen: { from: number; to: number }[], aktiv: number) { + view.dispatch(view.state.tr.setMeta(key, stellen.filter(x => x.to > x.from).map((x, i) => Decoration.inline(x.from, x.to, { class: `pw-s-suchtreffer${i === aktiv ? ' pw-s-suchtreffer--aktiv' : ''}` })))); +} diff --git a/src/features/suche/suche.css b/src/features/suche/suche.css new file mode 100644 index 0000000..ada0c4c --- /dev/null +++ b/src/features/suche/suche.css @@ -0,0 +1,10 @@ +.pw-kapitelsuche, .pw-projektsuche { display: flex; flex-direction: column; gap: 10px; font-size: 13px; } +.pw-kapitelsuche { padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface); } +.pw-suche-felder { display: flex; gap: 8px; } +.pw-suche-felder > * { flex: 1; min-width: 0; } +.pw-suche-optionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.pw-suche-ergebnisse { max-height: 48vh; overflow: auto; } +.pw-suche-ergebnis { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; width: 100%; text-align: left; font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; cursor: pointer; } +.pw-suche-ergebnis[aria-pressed="true"] { border-color: var(--accent); } +.pw-s-suchtreffer { background: var(--mark-gelb); } +.pw-s-suchtreffer--aktiv { background: var(--mark-rosa); outline: 1px solid var(--accent); } diff --git a/src/features/suche/suche.test.tsx b/src/features/suche/suche.test.tsx new file mode 100644 index 0000000..1e14083 --- /dev/null +++ b/src/features/suche/suche.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { demoFs } from '../../../fixtures/demoProjekt'; +import { useStore } from '../../state/store'; +import { festeSprache } from '../../i18n'; +import { BestaetigungHost } from '../../ui'; +import type { Editor } from '@tiptap/core'; +import { ankerErzeugen, klartext } from '@core/text'; +import { SchreibEditor } from '../schreiben/Editor'; +import Projektsuche from './Projektsuche'; +import { useTastenkuerzel } from '../../app/tastenkuerzel'; + +beforeEach(async () => { await useStore.getState().projektLaden(demoFs()); }); +afterEach(() => { festeSprache('de'); }); +describe('Suchoberfläche', () => { + it('öffnet mit ⌘F die Kapitelsuche und springt zu einem Projekttreffer im Editor', async () => { + function Kuerzel() { useTastenkuerzel(); return null; } + const { container } = render(<>); + await act(async () => { fireEvent.keyDown(window, { key: 'f', metaKey: true }); }); + expect(screen.getByRole('searchbox', { name: 'Suchen' })).toHaveFocus(); + act(() => useStore.getState().volltext.oeffneKapitel(false)); + const dialog = render( {}} />); + fireEvent.change(screen.getByRole('searchbox', { name: 'Suchen' }), { target: { value: 'Volvo' } }); + const ergebnis = within(screen.getByRole('region', { name: 'Manuskript' })).getAllByRole('button').find(x => x.textContent?.includes('Flut'))!; + act(() => { fireEvent.click(ergebnis); }); + dialog.unmount(); + const ed = (container.querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; + expect(container.querySelector('.pw-s-markierung')?.textContent).toBe('Volvo'); + expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.from + 5)).toBe('Volvo'); + }); + it('markiert alle Kapiteltreffer, navigiert mit Enter und ersetzt wörtlich', async () => { + useStore.getState().volltext.oeffneKapitel(true); + const { container } = render(); + const eingabe = screen.getByRole('searchbox', { name: 'Suchen' }); + fireEvent.change(eingabe, { target: { value: 'ich' } }); + expect(container.querySelectorAll('.pw-s-suchtreffer').length).toBeGreaterThan(0); + const status = screen.getByRole('search').querySelector('[role="status"]')!.textContent; + fireEvent.keyDown(eingabe, { key: 'Enter' }); + expect(screen.getByRole('search').querySelector('[role="status"]')!.textContent).not.toBe(status); + fireEvent.change(screen.getByRole('textbox', { name: 'Ersetzen durch' }), { target: { value: '"Hafen"' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Ersetzen' })); }); + expect(useStore.getState().daten!.kapitel.flut!.text).toContain('"Hafen"'); + const ed = (container.querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; + expect(ed.state.doc.textContent).toContain('"Hafen"'); + act(() => { expect(ed.commands.undo()).toBe(true); }); + expect(ed.state.doc.textContent).not.toContain('"Hafen"'); + fireEvent.change(eingabe, { target: { value: 'xyzxyz' } }); + expect(screen.getByText('Keine Treffer.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Alle ersetzen' })).toBeDisabled(); + }); + it('zeigt DE/EN Leerzustände und deaktiviert Ersetzen', () => { + festeSprache('en'); + render( {}} />); + expect(screen.getByText('Enter a search term.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Replace' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Replace all' })).toBeDisabled(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Find' }), { target: { value: 'xyzxyz' } }); + expect(screen.getByText('No matches.')).toBeInTheDocument(); + }); + it('fragt vor Projekt-Ersetzen nach, Abbrechen ändert nichts und Undo nimmt alle Notizen zurück', async () => { + await useStore.getState().speichereFigur({ ...useStore.getState().daten!.figuren.paul!, notizen: 'Hafen', plan: { Ziel: 'Hafen' } }); + await useStore.getState().speichereOrt({ ...useStore.getState().daten!.orte.lindenhof!, notizen: 'Hafen' }); + const vorher = useStore.getState().daten; + render(<> {}} />); + fireEvent.change(screen.getByRole('searchbox', { name: 'Suchen' }), { target: { value: 'Hafen' } }); + expect(screen.getByRole('heading', { name: 'Manuskript' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Plan' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Notizen (inkl. privater)' })).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: 'Ersetzen durch' }), { target: { value: 'Ole' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Alle ersetzen' })); }); + const frage = screen.getByRole('dialog', { name: 'Ersetzen bestätigen' }); + fireEvent.click(within(frage).getByRole('button', { name: 'Abbrechen' })); + expect(useStore.getState().daten).toBe(vorher); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Alle ersetzen' })); }); + await act(async () => { fireEvent.click(within(screen.getByRole('dialog', { name: 'Ersetzen bestätigen' })).getByRole('button', { name: 'Alle ersetzen' })); }); + expect(useStore.getState().daten!.figuren.paul!.notizen).toBe('Ole'); + expect(useStore.getState().daten!.orte.lindenhof!.notizen).toBe('Ole'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Ersetzen in Plan und Notizen rückgängig machen' })); }); + expect(useStore.getState().daten!.figuren).toEqual(vorher!.figuren); + expect(useStore.getState().daten!.orte).toEqual(vorher!.orte); + }); + it('öffnet einen Manuskripttreffer mit Kapitelnummer und Anker', () => { + render( {}} />); + fireEvent.change(screen.getByRole('searchbox', { name: 'Suchen' }), { target: { value: 'Volvo' } }); + const liste = screen.getByRole('region', { name: 'Manuskript' }); + const knopf = within(liste).getAllByRole('button')[0]!; + expect(knopf.textContent).toMatch(/Kapitel \d+ ·/); + fireEvent.click(knopf); + expect(useStore.getState().bereich).toBe('schreiben'); + expect(useStore.getState().markierung!.anker.exakt.toLowerCase()).toBe('volvo'); + }); + it('sucht im ungespeicherten Editor und ersetzt dessen aktuellen Text', async () => { + useStore.getState().volltext.oeffneKapitel(true); + const { container } = render(); + const ed = (container.querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; + act(() => { ed.commands.insertContent('Hafen Hafen '); }); + fireEvent.change(screen.getByRole('searchbox', { name: 'Suchen' }), { target: { value: 'Hafen' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Ersetzen durch' }), { target: { value: 'Ole' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Alle ersetzen' })); }); + expect(useStore.getState().daten!.kapitel.flut!.text).toContain('Ole Ole'); + expect(ed.state.doc.textContent).toContain('Ole Ole'); + }); + it('sucht Notizanker nach Ersetzen neu und markiert entfernte Zitate als verwaist', async () => { + const s = useStore.getState(); + const text = klartext(s.daten!.kapitel.flut!.text); + const start = text.indexOf('Volvo'); + const notiz = await s.anmerkungAnlegen(ankerErzeugen('flut', text, start, start + 5), 'Hafen', null); + render(); + await act(async () => { await useStore.getState().volltext.ersetzen('Volvo', 'Lotte', {}, { kapitel: 'flut' }); }); + expect(useStore.getState().schreibenLive!.verwaist).toContain(notiz.id); + expect(useStore.getState().daten!.anmerkungen.find(x => x.id === notiz.id)!.anker.exakt).toBe('Volvo'); + }); +}); diff --git a/src/i18n/de/index.ts b/src/i18n/de/index.ts index 9a15ed2..1d4915e 100644 --- a/src/i18n/de/index.ts +++ b/src/i18n/de/index.ts @@ -24,8 +24,10 @@ import { recherche } from './recherche'; import { vorschau } from './vorschau'; import { abgabe } from './abgabe'; import { funktionen } from './funktionen'; +import { suche } from './suche'; export const de = { + suche, gemeinsam, sprache, app, diff --git a/src/i18n/de/suche.ts b/src/i18n/de/suche.ts new file mode 100644 index 0000000..b553969 --- /dev/null +++ b/src/i18n/de/suche.ts @@ -0,0 +1,14 @@ +export const suche = { + kapitel: 'Suchen und Ersetzen', projekt: 'Im Projekt suchen und ersetzen', + suchen: 'Suchen', ersatz: 'Ersetzen durch', ersetzen: 'Ersetzen', alle: 'Alle ersetzen', + wort: 'Ganzes Wort', case: 'Groß- und Kleinschreibung beachten', + leer: 'Suchbegriff eingeben.', keine: 'Keine Treffer.', treffer: '{n} Treffer', + weiter: 'Nächster Treffer', zurueck: 'Vorheriger Treffer', schliessen: 'Suche schließen', + bereiche: { manuskript: 'Manuskript', plan: 'Plan', notizen: 'Notizen (inkl. privater)' }, + kapitelTreffer: 'Kapitel {nr} · {titel}', + rueckfrage: '{n} Treffer in {kapitel} Kapiteln sowie Plan und Notizen ersetzen? Kapitel werden vorher gesichert. Plan und Notizen können als ganze Aktion rückgängig gemacht werden.', + bestaetigen: 'Ersetzen bestätigen', undo: 'Ersetzen in Plan und Notizen rückgängig machen', + undoKonflikt: 'Plan oder Notizen wurden seit dem Ersetzen bearbeitet. Rückgängig würde diese Änderungen überschreiben.', + fehler: '{titel}: {grund}', sicherungFehlt: 'Kein Schnappschuss angelegt; Kapitel bleibt unverändert.', + feldGeaendert: 'Text wurde während der Aktion geändert; dieser Treffer wurde übersprungen.', +}; diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 71aff2f..d98e743 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -25,8 +25,10 @@ import { recherche } from './recherche'; import { vorschau } from './vorschau'; import { abgabe } from './abgabe'; import { funktionen } from './funktionen'; +import { suche } from './suche'; export const en: Woerterbuch = { + suche, gemeinsam, sprache, app, diff --git a/src/i18n/en/suche.ts b/src/i18n/en/suche.ts new file mode 100644 index 0000000..4dd5624 --- /dev/null +++ b/src/i18n/en/suche.ts @@ -0,0 +1,16 @@ +import type { Form } from '@core/i18n'; +import type { suche as de } from '../de/suche'; +export const suche: Form = { + kapitel: 'Find and replace', projekt: 'Find and replace in project', + suchen: 'Find', ersatz: 'Replace with', ersetzen: 'Replace', alle: 'Replace all', + wort: 'Whole word', case: 'Match case', + leer: 'Enter a search term.', keine: 'No matches.', treffer: '{n} matches', + weiter: 'Next match', zurueck: 'Previous match', schliessen: 'Close search', + bereiche: { manuskript: 'Manuscript', plan: 'Plan', notizen: 'Notes (including private)' }, + kapitelTreffer: 'Chapter {nr} · {titel}', + rueckfrage: 'Replace {n} matches in {kapitel} chapters, plan and notes? Chapters will be backed up first. Plan and notes can be undone as one action.', + bestaetigen: 'Confirm replacement', undo: 'Undo replacement in plan and notes', + undoKonflikt: 'Plan or notes have been edited since replacement. Undo would overwrite those changes.', + fehler: '{titel}: {grund}', sicherungFehlt: 'No snapshot created; chapter remains unchanged.', + feldGeaendert: 'Text changed during the action; this match was skipped.', +}; diff --git a/src/state/slices/statistik.ts b/src/state/slices/statistik.ts index f80a710..37a7ddd 100644 --- a/src/state/slices/statistik.ts +++ b/src/state/slices/statistik.ts @@ -156,12 +156,12 @@ export const createStatistikSlice: StateCreator = const neu = woerter.buch(s.daten); if (neu === stand) return; const nurStand = - aussetzen > 0 || s.daten.archiv !== vorher.daten.archiv || s.externeAenderung !== vorher.externeAenderung || (s.dialog !== null && NICHT_ZAEHLEN.has(s.dialog.id)); + aussetzen > 0 || s.volltext.laeuft || s.daten.archiv !== vorher.daten.archiv || s.externeAenderung !== vorher.externeAenderung || (s.dialog !== null && NICHT_ZAEHLEN.has(s.dialog.id)); const b = bucheAenderung(stand, neu, new Date(), puffer, nurStand); stand = neu; puffer = b.puffer; const schonEintrag = !!s.statistik.offen[b.tag] || !!s.daten.statistik?.tage[b.tag]; - if (b.geschrieben === 0 && !schonEintrag) return; + if (b.geschrieben === 0 && !schonEintrag && !s.volltext.laeuft) return; setze({ offen: offenHinzufuegen(s.statistik.offen, b) }); spaeterSichern(); }); diff --git a/src/state/slices/suche.test.ts b/src/state/slices/suche.test.ts new file mode 100644 index 0000000..f376926 --- /dev/null +++ b/src/state/slices/suche.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { demoFs } from '../../../fixtures/demoProjekt'; +import { erstelleStore } from '../store'; +import { tagSchluessel } from '@core/statistik'; +import { sucheProjekt } from '@core/suche'; + +async function laden() { + const fs = demoFs(); + const store = erstelleStore(); + await store.getState().projektLaden(fs); + return { fs, store, s: () => store.getState() }; +} +describe('Ersetzen-Sicherheitsnetz', () => { + it('speichert Sonderzeichen in privaten Figur- und Ortsnotizen wörtlich und nimmt die ganze Aktion zurück', async () => { + const { fs, s } = await laden(); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Hafen' }); + await s().speichereOrt({ ...s().daten!.orte.lindenhof!, notizen: 'Hafen' }); + const ersatz = '"Ole-Jan" \\ * _ # = -'; + expect((await s().volltext.ersetzen('Hafen', ersatz, {}, {})).fehler).toEqual([]); + const neu = erstelleStore(); + await neu.getState().projektLaden(fs); + expect(neu.getState().daten!.figuren.paul!.notizen).toBe(ersatz); + expect(neu.getState().daten!.orte.lindenhof!.notizen).toBe(ersatz); + await s().volltext.undo(); + await neu.getState().projektLaden(fs); + expect(neu.getState().daten!.figuren.paul!.notizen).toBe('Hafen'); + expect(neu.getState().daten!.orte.lindenhof!.notizen).toBe('Hafen'); + }); + it('erhält Verweisziele und Anzeigen bei einer projektweiten Namensersetzung und nach Undo', async () => { + const { fs, s } = await laden(); + const vorher = 'Paul und [[figur:paul|Paul]] bei [[ort:lindenhof|Paul]]'; + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: vorher, plan: { Ziel: vorher } }); + expect((await s().volltext.ersetzen('Paul', 'Ole', {}, {})).fehler).toEqual([]); + const neu = erstelleStore(); + await neu.getState().projektLaden(fs); + expect(neu.getState().daten!.figuren.paul!.notizen).toBe('Ole und [[figur:paul|Paul]] bei [[ort:lindenhof|Paul]]'); + expect(neu.getState().daten!.figuren.paul!.plan.Ziel).toBe('Ole und [[figur:paul|Paul]] bei [[ort:lindenhof|Paul]]'); + expect(neu.getState().daten!.figuren.paul!.id).toBe('paul'); + await s().volltext.undo(); + await neu.getState().projektLaden(fs); + expect(neu.getState().daten!.figuren.paul!.notizen).toBe(vorher); + expect(neu.getState().daten!.figuren.paul!.plan.Ziel).toBe(vorher); + }); + it('sichert im Projekt jedes betroffene Kapitel vor dem Schreiben und überspringt nur fehlgeschlagene Sicherungen', async () => { + const { fs, s } = await laden(); + await s().kapitelTextSpeichern('flut', 'Hafen Hafen'); + await s().kapitelTextSpeichern('lotte', 'Hafen'); + await s().kapitelTextSpeichern('grundriss', 'Ole'); + const vorher = await fs.lesen('manuskript/lotte.md'); + const schreiben = fs.schreiben.bind(fs); + const spy = vi.spyOn(fs, 'schreiben').mockImplementation(async (pfad, text) => { + if (pfad.startsWith('.versionen/manuskript/flut.md/')) throw new Error('Test'); + if (pfad === 'manuskript/lotte.md') { + const versionen = await fs.auflisten('.versionen/manuskript/lotte.md'); + expect(await Promise.all(versionen.map(pfad => fs.lesen(pfad)))).toContain(vorher); + } + return schreiben(pfad, text); + }); + const r = await s().volltext.ersetzen('Hafen', 'Ole', {}, {}); + expect(r.fehler).toHaveLength(1); + expect(r.fehler[0]).toContain('Flut'); + expect(s().daten!.kapitel.flut!.text).toBe('Hafen Hafen'); + expect(s().daten!.kapitel.lotte!.text).toBe('Ole'); + expect(spy.mock.calls.some(([pfad]) => pfad === 'manuskript/grundriss.md')).toBe(false); + }); + it('legt beim ersten Ersetzen eines Tages einen Stand mit null geschrieben in statistik.json ab', async () => { + const { fs, s } = await laden(); + await s().volltext.ersetzen('Volvo', 'Volvo am Hafen', {}, { kapitel: 'flut' }); + const statistik = JSON.parse((await fs.lesen('statistik.json'))!); + expect(statistik.tage[tagSchluessel(new Date())]).toMatchObject({ geschrieben: 0 }); + expect(statistik.tage[tagSchluessel(new Date())].stand).toBeGreaterThan(0); + }); + it('führt gleichzeitig gestartete Aktionen nur einmal aus', async () => { + const { s } = await laden(); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Hafen' }); + await Promise.all([ + s().volltext.ersetzen('Hafen', 'Ole', {}, {}), + s().volltext.ersetzen('Hafen', 'Lotte', {}, {}), + ]); + expect(s().daten!.figuren.paul!.notizen).toBe('Ole'); + expect(s().volltext.kannUndo).toBe(true); + await s().volltext.undo(); + expect(s().daten!.figuren.paul!.notizen).toBe('Hafen'); + }); + it('legt vor jedem Lauf einen neuen Schnappschuss an, auch in derselben Sekunde', async () => { + const { fs, s } = await laden(); + await s().volltext.ersetzen('Volvo', 'Hafen', {}, { kapitel: 'flut' }); + await s().volltext.ersetzen('Hafen', 'Volvo', {}, { kapitel: 'flut' }); + expect(await fs.auflisten('.versionen/manuskript/flut.md')).toHaveLength(2); + }); + it('lässt bei Undo spätere Feldänderungen erhalten', async () => { + const { s } = await laden(); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Hafen' }); + await s().volltext.ersetzen('Hafen', 'Ole', {}, {}); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Lotte' }); + await expect(s().volltext.undo()).rejects.toThrow(); + expect(s().daten!.figuren.paul!.notizen).toBe('Lotte'); + }); + it('sichert nur betroffene Kapitel vorher und führt nur stand nach', async () => { + const { fs, s } = await laden(); + const tag = tagSchluessel(new Date()); + await s().speichereStatistik({ version: 1, ziel: { tagWoerter: 0 }, tage: { [tag]: { geschrieben: 7, stand: 0 } } }); + const vorher = await fs.lesen('manuskript/flut.md'); + const schreiben = vi.spyOn(fs, 'schreiben'); + await s().volltext.ersetzen('Volvo', '"Volvo am Hafen"', {}, { kapitel: 'flut' }); + await s().statistik.sichern(); + const versionen = await fs.auflisten('.versionen/manuskript/flut.md'); + expect(versionen.length).toBeGreaterThan(0); + expect(await fs.lesen(versionen[0]!)).toBe(vorher); + expect(s().daten!.kapitel.flut!.text).toContain('"Volvo am Hafen"'); + expect(s().daten!.statistik!.tage[tag]!.geschrieben).toBe(7); + expect(s().daten!.statistik!.tage[tag]!.stand).toBeGreaterThan(0); + expect(schreiben.mock.calls.some(([pfad]) => pfad === 'manuskript/lotte.md')).toBe(false); + }); + it('lässt ein Kapitel bei fehlgeschlagenem Schnappschuss unverändert und nennt es', async () => { + const { fs, s } = await laden(); + const vorher = s().daten!.kapitel.flut!.text; + const schreiben = fs.schreiben.bind(fs); + vi.spyOn(fs, 'schreiben').mockImplementation(async (pfad, text) => { + if (pfad.startsWith('.versionen/')) throw new Error('Test'); + return schreiben(pfad, text); + }); + const r = await s().volltext.ersetzen('Volvo', 'Hafen', {}, { kapitel: 'flut' }); + expect(s().daten!.kapitel.flut!.text).toBe(vorher); + expect(r.fehler[0]).toContain('Flut'); + }); + it('nimmt Plan und private Notizen als ganze Aktion zurück, auch nach erneutem Laden', async () => { + const { fs, s, store } = await laden(); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Hafen', plan: { Ziel: 'Hafen' } }); + await s().speichereOrt({ ...s().daten!.orte.lindenhof!, notizen: 'Hafen' }); + const vorher = structuredClone(s().daten!); + await s().volltext.ersetzen('Hafen', 'Ole', {}, {}); + expect(s().daten!.figuren.paul!.notizen).toBe('Ole'); + expect(s().daten!.orte.lindenhof!.notizen).toBe('Ole'); + await s().volltext.undo(); + expect(s().daten!.figuren).toEqual(vorher.figuren); + expect(s().daten!.orte).toEqual(vorher.orte); + await store.getState().projektLaden(fs); + expect(s().daten!.figuren).toEqual(vorher.figuren); + expect(s().volltext.kannUndo).toBe(false); + }); + it('ersetzt gezielt einen einzelnen Projekttreffer', async () => { + const { s } = await laden(); + await s().speichereFigur({ ...s().daten!.figuren.paul!, notizen: 'Paul Paul' }); + const treffer = sucheProjekt(s().daten!, 'Paul').find(x => x.feld.pfad.join('.') === 'figuren.paul.notizen'); + expect(treffer).toBeDefined(); + await s().volltext.ersetzen('Paul', 'Ole', {}, { einzeln: treffer }); + expect(s().daten!.figuren.paul!.notizen).toContain('Ole'); + expect(s().daten!.kapitel.flut!.text).not.toContain('Ole'); + }); +}); diff --git a/src/state/slices/suche.ts b/src/state/slices/suche.ts new file mode 100644 index 0000000..20a54a8 --- /dev/null +++ b/src/state/slices/suche.ts @@ -0,0 +1,109 @@ +/** Lokale Volltext-Aktionen; Persistenz über öffentliche Store-Aktionen, ohne KI. */ +import type { StateCreator } from 'zustand'; +import type { ProjektDaten } from '@core/model/types'; +import { kapitelPfad } from '@core/projekt'; +import { ersetzeTreffer, feldSetzen, projektFelder, sucheProjekt, type ProjektTreffer, type SuchFeld, type SuchOptionen } from '@core/suche'; +import { texte, f } from '../../i18n'; +import type { Store } from '../store'; +interface UndoFeld { feld: SuchFeld; vorher: string; nachher: string } +export interface SucheSlice { + volltext: { + kapitelOffen: boolean; oeffneKapitel(offen: boolean): void; laeuft: boolean; kannUndo: boolean; + ersetzen(suche: string, ersatz: string, optionen: SuchOptionen, umfang: { kapitel?: string; einzeln?: ProjektTreffer }): Promise<{ fehler: string[] }>; + undo(): Promise; + editorErsetzungAbholen(kapitel: string, text: string): boolean; + }; +} +export const createSucheSlice: StateCreator = (set, get, api) => { + let undo: UndoFeld[] = []; + let reserviert = false; + const editorErsetzungen = new Map(); + const setze = (teil: Partial) => set(s => ({ volltext: { ...s.volltext, ...teil } })); + api.subscribe((s, vorher) => { + if (s.fs !== vorher.fs || (s.ladeStatus === 'bereit' && vorher.ladeStatus !== 'bereit')) { + undo = []; editorErsetzungen.clear(); setze({ kannUndo: false, kapitelOffen: false }); + } + }); + async function speichern(d: ProjektDaten, feld: SuchFeld): Promise { + const s = get(), [root, id] = feld.pfad; + switch (root) { + case 'kapitel': return s.kapitelTextSpeichern(String(id), d.kapitel[String(id)]!.text); + case 'plaene': return s.speicherePlan(d.plaene[String(id)]!); + case 'figuren': return s.speichereFigur(d.figuren[String(id)]!); + case 'orte': return s.speichereOrt(d.orte[String(id)]!); + case 'gruppen': return s.speichereGruppe(d.gruppen[String(id)]!); + case 'projekt': return s.speichereProjekt(d.projekt); + case 'akte': return s.speichereAkte(d.akte); + case 'straenge': return s.speichereStraenge(d.straenge); + case 'ideen': return s.speichereIdeen(d.ideen); + case 'motive': return s.speichereMotive(d.motive ?? []); + case 'zeitleiste': return s.speichereZeitleiste(d.zeitleiste!); + case 'fadenplanung': return s.speichereFadenplanung(d.fadenplanung!); + case 'boegen': return s.speichereBoegen(d.boegen ?? []); + case 'marken': return s.speichereMarken(d.marken!); + case 'anmerkungen': return s.speichereAnmerkungen(d.anmerkungen); + case 'recherche': return s.speichereRecherche(d.recherche!); + case 'entscheidungen': return s.speichereEntscheidungen(d.entscheidungen); + } + } + return { volltext: { + kapitelOffen: false, laeuft: false, kannUndo: false, + oeffneKapitel: kapitelOffen => setze({ kapitelOffen }), + editorErsetzungAbholen(kapitel, text) { + if (editorErsetzungen.get(kapitel) !== text) return false; + editorErsetzungen.delete(kapitel); + return true; + }, + async ersetzen(suche, ersatz, optionen, umfang) { + if (reserviert || get().volltext.laeuft) return { fehler: [] }; + reserviert = true; + try { + await get().editorSichern(); + await get().alleSpeichernAbwarten(); + const basis = get().daten, fs = get().fs; + if (!basis) return { fehler: [] }; + const alle = sucheProjekt(basis, suche, optionen).filter(t => !umfang.kapitel || (t.feld.bereich === 'manuskript' && t.feld.kapitel === umfang.kapitel)); + const treffer = umfang.einzeln ? alle.filter(t => JSON.stringify(t.feld.pfad) === JSON.stringify(umfang.einzeln!.feld.pfad) && t.start === umfang.einzeln!.start && t.feld.text === umfang.einzeln!.feld.text) : alle; + if (!treffer.length) return { fehler: [] }; + const gruppen = new Map(); + for (const t of treffer) { const key = JSON.stringify(t.feld.pfad); gruppen.set(key, [...(gruppen.get(key) ?? []), t]); } + const fehler: string[] = []; + undo = []; setze({ laeuft: true, kannUndo: false }); + try { + for (const liste of gruppen.values()) { + const feld = liste[0]!.feld; + const nachher = ersetzeTreffer(feld.text, liste, ersatz, feld.markdown); + if (nachher === feld.text) continue; + try { + if (get().fs !== fs || !get().daten) break; + if (feld.bereich === 'manuskript') { + const version = await get().schnappschuss(kapitelPfad(feld.kapitel!), 'ersetzen'); + if (!version) throw new Error(texte().suche.sicherungFehlt); + editorErsetzungen.set(feld.kapitel!, nachher); + } + if (get().fs !== fs || !get().daten) break; + const aktuellesFeld = projektFelder(get().daten!).find(x => JSON.stringify(x.pfad) === JSON.stringify(feld.pfad)); + if (aktuellesFeld?.text !== feld.text) throw new Error(texte().suche.feldGeaendert); + // Store-Persistenz aktualisiert optimistisch. Auch ein fehlgeschlagener Schreibversuch + // behält daher das Sicherheitsnetz für die Änderung im Speicher. + if (feld.bereich !== 'manuskript') undo.push({ feld, vorher: feld.text, nachher }); + await speichern(feldSetzen(get().daten!, feld.pfad, nachher), feld); + } catch (e) { fehler.push(f(texte().suche.fehler, { titel: feld.titel, grund: e instanceof Error ? e.message : String(e) })); } + } + await get().statistik.sichern(); + } finally { setze({ laeuft: false, kannUndo: undo.length > 0 }); } + return { fehler }; + } finally { reserviert = false; } + }, + async undo() { + if (!undo.length || get().volltext.laeuft || !get().daten) return; + const aktuell = projektFelder(get().daten!); + if (undo.some(u => !aktuell.some(x => JSON.stringify(x.pfad) === JSON.stringify(u.feld.pfad) && x.text === u.nachher))) throw new Error(texte().suche.undoKonflikt); + setze({ laeuft: true }); + try { + for (const u of [...undo].reverse()) await speichern(feldSetzen(get().daten!, u.feld.pfad, u.vorher), u.feld); + undo = []; setze({ kannUndo: false }); + } finally { setze({ laeuft: false }); } + }, + } }; +}; diff --git a/src/state/store.ts b/src/state/store.ts index e87a581..4546eac 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -68,6 +68,7 @@ import { createRechercheSlice, type RechercheSlice } from './slices/recherche'; import { createStatistikSlice, type StatistikSlice } from './slices/statistik'; import { createStrukturSlice, type StrukturSlice } from './slices/struktur'; import { createZeitSlice, type ZeitSlice } from './slices/zeit'; +import { createSucheSlice, type SucheSlice } from './slices/suche'; export type Bereich = | 'schreiben' @@ -90,6 +91,7 @@ export type DialogId = | 'ki-pruefen' | 'ki-einstellungen' | 'schnellsuche' + | 'projektsuche' /** E16: Funktionsumfang anpassen (aus den Einstellungen) */ | 'funktionen' // Werkzeuge G1–G8 (Komponenten in src/features//, Zuordnung in src/app/dialoge.tsx) @@ -265,6 +267,7 @@ export interface Basis { } export type Store = Basis & + SucheSlice & SchreibenSlice & PlanungSlice & NetzSlice & @@ -988,6 +991,7 @@ export function erstelleStore(): UseBoundStore> { return create()((...a) => ({ ...createBasis(...a), ...createSchreibenSlice(...a), + ...createSucheSlice(...a), ...createPlanungSlice(...a), ...createNetzSlice(...a), ...createHinweiseSlice(...a),