Skip to content
Closed
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
5 changes: 3 additions & 2 deletions core/abgabe/expose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import { AlignmentType, Document, HeadingLevel, HighlightColor, LineRuleType, Packer, Paragraph, TextRun } from 'docx';
import { buchSprache, fmt, localeVon, texteJe, type Sprache } from '../i18n';
import { nachRolle } from '../figuren/namen';
import type { Id, ProjektDaten } from '../model/types';
import { wortzahl } from '../text';
import { normseitenZaehlen } from './normseite';
Expand Down Expand Up @@ -162,8 +163,8 @@ export function exposeGeruest(daten: ProjektDaten, optionen: ExposeOptionen): Ex

bloecke.push({ art: 'ueberschrift', ebene: 1, text: t.figuren });
const figuren = Object.values(daten.figuren)
.filter((f) => optionen.nebenfiguren !== false || f.rolle === 'haupt')
.sort((a, b) => (a.rolle === b.rolle ? a.name.localeCompare(b.name, sprache) : a.rolle === 'haupt' ? -1 : 1));
.filter((f) => f.rolle !== 'statist' && (optionen.nebenfiguren !== false || f.rolle === 'haupt'))
.sort(nachRolle((a, b) => a.name.localeCompare(b.name, sprache)));
if (figuren.length === 0) bloecke.push({ art: 'absatz', text: [P(t.figurenFrage)] });
for (const f of figuren) {
bloecke.push({ art: 'punkt', text: [F(f.name), T(' – '), f.kurz?.trim() ? T(f.kurz.trim()) : P(t.kurzbeschreibung)] });
Expand Down
1 change: 1 addition & 0 deletions core/abgabe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
type ExposeOptionen,
type ExposeStueck,
} from './expose';
export { personenAuswahl, personenKapitel, personenverzeichnis, type PersonenOptionen } from './personen';
export {
exportiereLektoratsmappe,
HINWEIS_TYP_NAME,
Expand Down
58 changes: 58 additions & 0 deletions core/abgabe/personen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { demoFs } from '../../fixtures/demoProjekt';
import { berechneIndex } from '../index/index';
import type { ProjektDaten } from '../model/types';
import { ladeProjekt } from '../projekt';
import { exposeMarkdown } from './expose';
import { personenAuswahl, personenKapitel, personenverzeichnis } from './personen';

let daten: ProjektDaten;
let erst: Record<string, string>;
beforeAll(async () => {
daten = await ladeProjekt(demoFs());
erst = berechneIndex(daten).erstesVorkommen;
});

describe('Personenverzeichnis', () => {
it('nach Gruppen: Hauptgruppen als Abschnitte, Rest unter „Weitere Figuren“; nie private Felder', () => {
const d: ProjektDaten = {
...daten,
figuren: { ...daten.figuren, ole: { ...daten.figuren.ole!, aliase: ['Fenster-Ole'], wahrheit: 'GEHEIM', notizen: 'PRIVAT' } },
};
const md = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'gruppen' }));
expect(md).toMatch(/^# Personen/);
expect(md).toContain('## Nordhaven');
expect(md).toContain('## Weitere Figuren');
expect(md).toContain('**Ole**');
expect(md).toContain('auch „Fenster-Ole“');
expect(md).not.toContain('GEHEIM');
expect(md).not.toContain('PRIVAT');
// bewusst vage Figuren bleiben draußen, geplante (noch nicht im Text) auch
const vage = Object.values(d.figuren).find((f) => f.existenz === 'bewusst_vage');
if (vage) expect(md).not.toContain(vage.name);
});

it('nach Rolle; Spitznamen abschaltbar; Statisten nur auf Wunsch', () => {
const d: ProjektDaten = { ...daten, figuren: { ...daten.figuren, malte: { ...daten.figuren.malte!, rolle: 'statist', aliase: ['M.'] } } };
const ohne = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', spitznamen: false }));
expect(ohne).toContain('## Hauptfiguren');
expect(ohne).not.toContain('Malte');
const mit = exposeMarkdown(personenverzeichnis(d, erst, { gliederung: 'rolle', statisten: true, spitznamen: false }));
expect(mit).toContain('**Malte**');
expect(mit).not.toContain('„M.“');
});

it('nur bis Kapitel X: keine Figuren, die erst später auftauchen', () => {
const erstes = daten.projekt.kapitel[0]!;
const bis = personenAuswahl(daten, erst, { gliederung: 'rolle', bisKapitel: erstes });
expect(bis.every((f) => erst[f.id] === erstes)).toBe(true);
expect(bis.length).toBeLessThan(personenAuswahl(daten, erst, { gliederung: 'rolle' }).length);
});

it('als Vorspann-Kapitel (Markdown mit fett/kursiv)', () => {
const k = personenKapitel(personenverzeichnis(daten, erst, { gliederung: 'rolle' }));
expect(k.titel).toBe('Personen');
expect(k.text).toContain('## Hauptfiguren');
expect(k.text).toMatch(/\*\*Paul\*\*/);
});
});
109 changes: 109 additions & 0 deletions core/abgabe/personen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Personenverzeichnis (G7 Abgabe): Figuren nach Gruppen oder Rolle, je Eintrag Name, Spitznamen, Kurzbeschreibung
* und Rolle in der Gruppe. Liefert dieselben Blöcke wie das Exposé (exposeDocx/exposeMarkdown) und als Markdown
* für einen Vorspann vor EPUB und Normseite. Private Felder (`wahrheit`, `notizen`, Planfelder) kommen nie hinein.
*/
import { fmt, texteJe, type Sprache } from '../i18n';
import type { Figur, Id, Kapitel, ProjektDaten } from '../model/types';
import { nachRolle } from '../figuren/namen';
import type { ExposeBlock, ExposeStueck } from './expose';

const T = texteJe(
{
titel: 'Personen',
haupt: 'Hauptfiguren',
neben: 'Nebenfiguren',
statist: 'Weitere Figuren',
ohneGruppe: 'Weitere Figuren',
auch: 'auch {namen}',
zitat: '„{text}“',
keine: 'Noch keine Figuren.',
},
{
titel: 'Characters',
haupt: 'Main characters',
neben: 'Supporting characters',
statist: 'Other characters',
ohneGruppe: 'Other characters',
auch: 'also {namen}',
zitat: '“{text}”',
keine: 'No characters yet.',
},
);

export interface PersonenOptionen {
/** nach Gruppen (Hauptgruppe) oder nach Rolle */
gliederung: 'gruppen' | 'rolle';
/** Statisten aufnehmen (Standard: nein) */
statisten?: boolean;
/** Spitznamen nennen (Standard: ja) */
spitznamen?: boolean;
/** nur Figuren, die bis zu diesem Kapitel im Text vorkommen (kein Spoiler); fehlt = alle im Text */
bisKapitel?: Id;
/** auch Figuren, die noch nicht im Text vorkommen (Standard: nein) */
geplante?: boolean;
/** „bewusst vage“ Figuren aufnehmen (Standard: nein – sie sollen offen bleiben) */
vage?: boolean;
}

/** Figuren für das Verzeichnis (Filter nach Optionen), in Listenreihenfolge. */
export function personenAuswahl(daten: ProjektDaten, erstesVorkommen: Record<Id, Id>, optionen: PersonenOptionen): Figur[] {
const reihe = daten.projekt.kapitel;
const bis = optionen.bisKapitel ? reihe.indexOf(optionen.bisKapitel) : reihe.length - 1;
return Object.values(daten.figuren)
.filter((f) => optionen.statisten || f.rolle !== 'statist')
.filter((f) => optionen.vage || f.existenz !== 'bewusst_vage')
.filter((f) => {
const erst = erstesVorkommen[f.id];
if (!erst) return !!optionen.geplante && !optionen.bisKapitel;
return reihe.indexOf(erst) <= bis;
})
.sort(nachRolle((a, b) => a.name.localeCompare(b.name, 'de')));
}

function eintrag(f: Figur, daten: ProjektDaten, optionen: PersonenOptionen, sprache: Sprache): ExposeStueck[] {
const t = T(sprache);
const teile: ExposeStueck[] = [{ text: f.name, art: 'fett' }];
const zusatz: string[] = [];
if (optionen.spitznamen !== false && f.aliase.length) zusatz.push(fmt(t.auch, { namen: f.aliase.map((a) => fmt(t.zitat, { text: a })).join(', ') }));
const rolle = f.gruppe ? daten.gruppen[f.gruppe]?.rollen?.[f.id] : undefined;
const beschreibung = [rolle, f.kurz?.trim()].filter(Boolean).join(', ');
if (beschreibung) teile.push({ text: ` – ${beschreibung}` });
if (zusatz.length) teile.push({ text: ` (${zusatz.join('; ')})`, art: 'kursiv' });
return teile;
}

/** Blöcke des Verzeichnisses (Überschrift, Abschnitte, Einträge als Aufzählung). */
export function personenverzeichnis(daten: ProjektDaten, erstesVorkommen: Record<Id, Id>, optionen: PersonenOptionen, sprache: Sprache = 'de'): ExposeBlock[] {
const t = T(sprache);
const figuren = personenAuswahl(daten, erstesVorkommen, optionen);
const bloecke: ExposeBlock[] = [{ art: 'titel', text: t.titel }];
if (!figuren.length) return [...bloecke, { art: 'absatz', text: [{ text: t.keine }] }];
const abschnitt = (titel: string, liste: Figur[]) => {
if (!liste.length) return;
bloecke.push({ art: 'ueberschrift', ebene: 1, text: titel });
for (const f of liste) bloecke.push({ art: 'punkt', text: eintrag(f, daten, optionen, sprache) });
};
if (optionen.gliederung === 'rolle') {
abschnitt(t.haupt, figuren.filter((f) => f.rolle === 'haupt'));
abschnitt(t.neben, figuren.filter((f) => f.rolle === 'neben'));
abschnitt(t.statist, figuren.filter((f) => f.rolle === 'statist'));
return bloecke;
}
const gruppen = Object.values(daten.gruppen).sort((a, b) => a.name.localeCompare(b.name, sprache));
for (const g of gruppen) abschnitt(g.name, figuren.filter((f) => f.gruppe === g.id));
abschnitt(t.ohneGruppe, figuren.filter((f) => !f.gruppe || !daten.gruppen[f.gruppe]));
return bloecke;
}

/** Als Kapitel für den Vorspann von EPUB und Normseite (Markdown wie im Manuskript: **fett**, *kursiv*). */
export function personenKapitel(bloecke: ExposeBlock[]): Kapitel {
const stuecke = (s: ExposeStueck[]) => s.map((x) => (x.art === 'fett' ? `**${x.text}**` : x.art === 'kursiv' ? ` *${x.text.trim()}*` : x.text)).join('');
const titel = bloecke.find((b) => b.art === 'titel');
const absaetze: string[] = [];
for (const b of bloecke) {
if (b.art === 'ueberschrift') absaetze.push(`## ${b.text}`);
else if (b.art === 'punkt' || b.art === 'absatz') absaetze.push(stuecke(b.text));
}
return { id: 'personenverzeichnis', titel: titel?.art === 'titel' ? titel.text : '', text: absaetze.join('\n\n') };
}
65 changes: 65 additions & 0 deletions core/figuren/besetzung.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* F-15 Besetzungsübersicht: Figuren × Kapitel. Je Zelle die wichtigste Art des Vorkommens (tritt auf > per
* Nachricht > erwähnt > im Text, Art offen) oder „plan“, wenn die Figur nur im Kapitelplan steht. Reine Ableitung
* aus dem Index, keine eigene Datei.
*/
import type { Erwaehnung, ErwaehnungsArt, Figur, Id, ProjektDaten } from '../model/types';
import { ROLLEN } from './namen';

export type BesetzungsZelle = ErwaehnungsArt | 'plan' | null;

export interface BesetzungsZeile {
figur: Figur;
/** je Kapitel (Reihenfolge wie `kapitel`) */
zellen: BesetzungsZelle[];
/** erste Erwähnung je Kapitel (zum Springen) */
erste: (Erwaehnung | null)[];
/** Kapitel mit Vorkommen im Text */
anzahl: number;
}

export interface Besetzung {
kapitel: Id[];
zeilen: BesetzungsZeile[];
/** Figuren mit Vorkommen im Text je Kapitel */
summen: number[];
}

/** Rang der Arten (gleiche Reihenfolge wie im Auftrittsstreifen der Figurenkarte). */
export const ART_RANG: Record<ErwaehnungsArt, number> = { auftritt: 4, nachricht: 3, erwaehnt: 2, unbestimmt: 1 };

export function besetzung(daten: ProjektDaten, erwaehnungen: Erwaehnung[]): Besetzung {
const kapitel = [...daten.projekt.kapitel];
const pos = new Map(kapitel.map((id, i) => [id, i]));
const figuren = Object.values(daten.figuren).sort(
(a, b) => ROLLEN.indexOf(a.rolle) - ROLLEN.indexOf(b.rolle) || a.name.localeCompare(b.name, 'de'),
);
const zeilen = new Map<Id, BesetzungsZeile>(
figuren.map((f) => [f.id, { figur: f, zellen: kapitel.map(() => null), erste: kapitel.map(() => null), anzahl: 0 }]),
);
for (const e of erwaehnungen) {
if (e.zielTyp !== 'figur') continue;
const z = zeilen.get(e.ziel);
const i = pos.get(e.kapitel);
if (!z || i === undefined) continue;
const alt = z.zellen[i];
if (!alt || alt === 'plan' || ART_RANG[e.art] > ART_RANG[alt]) z.zellen[i] = e.art;
if (!z.erste[i] || e.start < z.erste[i]!.start) z.erste[i] = e;
}
kapitel.forEach((id, i) => {
for (const f of daten.plaene[id]?.figuren ?? []) {
const z = zeilen.get(f);
if (z && !z.zellen[i]) z.zellen[i] = 'plan';
}
});
const summen = kapitel.map(() => 0);
for (const z of zeilen.values()) {
z.zellen.forEach((c, i) => {
if (c && c !== 'plan') {
z.anzahl += 1;
summen[i]! += 1;
}
});
}
return { kapitel, zeilen: [...zeilen.values()], summen };
}
75 changes: 75 additions & 0 deletions core/figuren/fakten.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { demoFs } from '../../fixtures/demoProjekt';
import type { FigurFakt, ProjektDaten } from '../model/types';
import { ladeProjekt, parseKiFakten, serializeKiFakten } from '../projekt';
import { faktenDerFigur, faktenHinweise, mischeFakten, setzeFaktStatus, werteVertraeglich, wertForm } from './index';

const SAAB = 'Dann nimmt er den Schlüssel und fährt mit seinem alten Saab 900 zum Lindenhof.';
const VOLVO = 'Ich steige aus dem Volvo, schlage die Tür zu und sehe ihm nach, bis er hinter den Lagerhallen verschwunden ist.';
const ORGELN = 'Vera erzählt von ihrer Werkstatt, in der sie alte Orgeln restauriert, Paul hört nur halb zu.';

const fakt = (figur: string, merkmal: string, wert: string, kapitel: string, text: string): FigurFakt => ({
schluessel: `fakt:${figur}|${merkmal.toLowerCase()}|${kapitel}/${text.slice(0, 20)}`,
figur,
merkmal,
wert,
kapitel,
zitat: { kapitel, text },
status: 'neu',
});

let daten: ProjektDaten;
beforeEach(async () => {
daten = await ladeProjekt(demoFs());
});

describe('Figurenfakten: Vergleich, Widersprüche, Planung (F-17)', () => {
it('Werte: Füllwörter fallen weg, enthalten = verträglich', () => {
expect(wertForm('seinem alten Saab 900')).toBe('seinem saab 900');
expect(werteVertraeglich('alter Saab 900', 'Saab')).toBe(true);
expect(werteVertraeglich('Saab', 'Volvo')).toBe(false);
expect(werteVertraeglich('Architekt', 'architekt')).toBe(true);
});

it('Widerspruch beim gleichen Merkmal → Hinweis „pruefen“ mit Figur-Bezug und beiden Zitaten', () => {
daten.kiFakten = { version: 1, eintraege: [fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB), fakt('paul', 'auto', 'Volvo', 'flut', VOLVO)] };
const g = faktenDerFigur(daten, 'paul');
expect(g).toHaveLength(1);
expect(g[0]!.widerspruch).toBe(true);
const h = faktenHinweise(daten).filter((x) => x.schluessel === 'fakt:widerspruch:paul|auto');
expect(h).toHaveLength(1);
expect(h[0]).toMatchObject({ typ: 'pruefen', plan: { figur: 'paul' } });
expect(h[0]!.zitate.map((z) => z.text)).toEqual([SAAB, VOLVO]);
});

it('Planabweichung: Planfeld gleichen Namens, kein Textwert passt', () => {
daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'Geigenbauerin' } };
daten.kiFakten = { version: 1, eintraege: [fakt('vera', 'Beruf', 'restauriert alte Orgeln', 'ebbe', ORGELN)] };
const g = faktenDerFigur(daten, 'vera')[0]!;
expect(g.plan).toEqual({ feld: 'Beruf', wert: 'Geigenbauerin', abweichend: true });
expect(faktenHinweise(daten).map((x) => x.schluessel)).toContain('fakt:plan:vera|beruf');
// passender Plan: kein Hinweis
daten.figuren.vera = { ...daten.figuren.vera!, plan: { ...daten.figuren.vera!.plan, Beruf: 'restauriert Orgeln' } };
expect(faktenHinweise(daten).filter((x) => x.schluessel.startsWith('fakt:plan:vera'))).toEqual([]);
});

it('verworfene und nicht mehr gültige Angaben zählen nicht; Mischen ersetzt nur die abgefragten Figuren', () => {
const a = fakt('paul', 'Auto', 'alter Saab 900', 'lotte', SAAB);
const b = fakt('paul', 'Auto', 'Volvo', 'flut', VOLVO);
const v = fakt('vera', 'Beruf', 'Orgeln', 'ebbe', ORGELN);
let ablage = mischeFakten(undefined, ['paul', 'vera'], [a, b, v]).ablage;
ablage = setzeFaktStatus(ablage, b.schluessel, 'verworfen');
daten.kiFakten = ablage;
expect(faktenDerFigur(daten, 'paul')[0]!.widerspruch).toBe(false);
// neuer Lauf nur für Paul: Verworfenes kommt nicht wieder, Vera bleibt
const zweiter = mischeFakten(ablage, ['paul'], [a, b]);
expect(zweiter.ablage.eintraege.filter((x) => x.figur === 'paul').map((x) => x.status).sort()).toEqual(['neu', 'verworfen']);
expect(zweiter.ablage.eintraege.some((x) => x.figur === 'vera')).toBe(true);
expect(zweiter.neu).toBe(0);
// Ablage übersteht Speichern
expect(parseKiFakten(serializeKiFakten(zweiter.ablage))).toEqual(zweiter.ablage);
// geänderter Text: Beleg nicht mehr gültig
daten.kapitel.lotte = { ...daten.kapitel.lotte!, text: 'Ganz anders.' };
expect(faktenDerFigur(daten, 'paul')).toEqual([]);
});
});
Loading
Loading