Phase 3, erster Teil: Vorlagen-Bausteine und Schwärzung

Entschieden und festgehalten: jede Akte trägt Kurzvermerke und Dokumente, die
Fallakte zusätzlich ihre Abschnitte. Die Trennung aus docs/05 bleibt damit
bestehen.

Eine Vorlage ist jetzt eine geordnete Liste von Bausteinen. Manche nehmen eine
Eingabe entgegen, andere liefern festen Text mit Platzhaltern. Damit tragen
dieselbe Struktur ein Vorfallsbericht, der fast nur aus Eingaben besteht, und
ein Sonderarbeitsvertrag, der fast nur aus Paragraphen und Unterschriftenzeilen
besteht. Eine neue Dokumentart bleibt ein Datensatz, kein Deployment.

Der Schwärzungs-Parser gibt Segmente zurück, nicht den vollen Text mit einem
Sichtbarkeits-Flag: was der Client ausblenden müsste, hat er dann schon. Die
Balkenlänge wird auf ein Raster von acht Zeichen gerundet, sonst verrät sie,
wie lang der geschwärzte Name war.

Ein Test vergleicht den TypeScript-Parser gegen die SQL-Funktion
strip_redactions aus der Migration. Laufen die beiden auseinander, findet die
Suche einen Begriff, den die Ansicht verbirgt — und der Treffer allein verrät
ihn dann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 12:39:35 +02:00
co-authored by Claude Opus 5
parent 5cbdabf337
commit d160741237
6 changed files with 410 additions and 7 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* Schwärzung im Freitext.
*
* Im Editor markiert man einen Bereich und wählt eine Stufe. Gespeichert wird
* als Auszeichnung:
*
* Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.
*
* Der Server ersetzt den Inhalt für niedrigere Stufen. **Der echte Wert
* verlässt den Server nicht** — deshalb gibt diese Datei Segmente zurück und
* nicht etwa den vollen Text mit einem Sichtbarkeits-Flag. Was der Client
* ausblenden müsste, hat er schon.
*
* Die Länge wird auf ein festes Raster gerundet. Ein Balken exakter Länge
* verriete sonst, wie lang der Name war, und bei kurzen Einschüben ist das
* genug, um zu raten. Siehe docs/05-akten-und-vorlagen.md.
*/
/** Auf dieses Vielfache wird die Länge eines Balkens gerundet. */
const LENGTH_GRID = 8
const MARKUP = /\[\[c([0-5])\]\]([\s\S]*?)\[\[\/c\1\]\]/g
export type Segment =
| { kind: 'text'; text: string }
| { kind: 'redacted'; requiredLevel: number; blockLength: number }
/** Auf das Raster gerundete Länge, mindestens ein Block. */
export function barLength(rawLength: number): number {
return Math.max(LENGTH_GRID, Math.ceil(rawLength / LENGTH_GRID) * LENGTH_GRID)
}
/**
* Zerlegt einen Text in Segmente und wendet dabei die Stufe des Lesers an.
* Bereiche über seiner Stufe kommen ohne ihren Inhalt zurück.
*/
export function redactForLevel(source: string, viewerLevel: number): Segment[] {
const segments: Segment[] = []
let cursor = 0
MARKUP.lastIndex = 0
let match: RegExpExecArray | null
while ((match = MARKUP.exec(source)) !== null) {
if (match.index > cursor) {
segments.push({ kind: 'text', text: source.slice(cursor, match.index) })
}
const requiredLevel = Number(match[1])
const content = match[2] ?? ''
if (viewerLevel >= requiredLevel) {
segments.push({ kind: 'text', text: content })
} else {
segments.push({
kind: 'redacted',
requiredLevel,
blockLength: barLength(content.length),
})
}
cursor = match.index + match[0].length
}
if (cursor < source.length) {
segments.push({ kind: 'text', text: source.slice(cursor) })
}
return segments
}
/**
* Die höchste Stufe, die in einem Text ausgezeichnet ist. Damit lässt sich an
* einem Eintrag anzeigen, dass er geschwärzte Stellen enthält, ohne den Text
* zu übertragen.
*/
export function highestRedactionLevel(source: string): number {
let highest = 0
MARKUP.lastIndex = 0
let match: RegExpExecArray | null
while ((match = MARKUP.exec(source)) !== null) {
highest = Math.max(highest, Number(match[1]))
}
return highest
}
/**
* Entfernt die Auszeichnung, behält den Inhalt. Für Leser, die alles sehen
* dürfen, und für den Volltextindex hoher Stufen.
*/
export function stripMarkup(source: string): string {
return source.replace(MARKUP, (_full, _level, content: string) => content)
}
/**
* Entfernt ausgezeichnete Bereiche samt Inhalt. Genau das tut auch die
* Funktion `strip_redactions` in der Migration, bevor der Suchvektor gefüllt
* wird — beide müssen dasselbe Ergebnis liefern.
*/
export function stripRedacted(source: string): string {
return source.replace(MARKUP, ' ')
}
/** Fügt eine Auszeichnung um einen markierten Bereich ein. */
export function markRedaction(source: string, start: number, end: number, level: number): string {
if (start >= end || level < 0 || level > 5) return source
return (
source.slice(0, start) +
`[[c${level}]]` +
source.slice(start, end) +
`[[/c${level}]]` +
source.slice(end)
)
}