Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions core/suche/importe.test.ts
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/);
});
});
125 changes: 125 additions & 0 deletions core/suche/index.ts
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}
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;
}
95 changes: 95 additions & 0 deletions core/suche/suche.test.ts
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));
});
});
4 changes: 2 additions & 2 deletions core/versionen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -175,4 +176,3 @@ export async function aufbewahrungAnwenden(fs: ProjektDateisystem, jetzt: Date,
}
return geloescht;
}

Loading
Loading