Strip parent mentions from NPC relationships text

Parents now live in the father/mother fields, so "son/daughter/child of
X [and Y]" is redundant noise in Relationships / Notes.

- app:npc:strip-parent-text removes that clause and tidies leftover
  punctuation ("Son of X, husband of Y" -> "Husband of Y"; standalone
  "Son of X and Y" -> cleared). Keeps "grandson of", "father of",
  "daughter-in-law of". 92 rows cleaned.
- data/npcs.csv regenerated from the DB with the cleaned text plus new
  Father / Mother name columns
- app:npc:import resolves those optional columns in a second pass, so a
  fresh install reproduces the parent links without app:npc:link-parents

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 15:41:02 +02:00
co-authored by Claude Sonnet 5
parent 54d326572e
commit 710b383bc1
3 changed files with 298 additions and 181 deletions
+31
View File
@@ -95,6 +95,7 @@ class ImportNpcsCommand extends Command
$created = $updated = 0;
$seen = [];
$byName = [];
foreach ($rows as $r) {
$key = $this->key($r['Family'], $r['Name']);
$seen[$key] = true;
@@ -109,6 +110,7 @@ class ImportNpcsCommand extends Command
} else {
++$updated;
}
$byName[mb_strtolower(trim($r['Name']))] = $npc;
// NOTE: gameNotes is deliberately never written here.
$npc->setAge('' === $r['Age'] ? null : (int) $r['Age'])
@@ -120,6 +122,35 @@ class ImportNpcsCommand extends Command
->setMysteryImportance(MysteryImportance::from($r['Mystery Importance']));
}
// Second pass: resolve the optional Father / Mother name columns now that
// every row exists. Unknown names are reported, not fatal.
$badParents = [];
foreach ($rows as $line => $r) {
if (!\array_key_exists('Father', $r) && !\array_key_exists('Mother', $r)) {
break;
}
$npc = $byName[mb_strtolower(trim($r['Name']))];
foreach (['Father' => 'setFather', 'Mother' => 'setMother'] as $col => $setter) {
$val = trim($r[$col] ?? '');
if ('' === $val) {
$npc->$setter(null);
continue;
}
$parent = $byName[mb_strtolower($val)] ?? null;
if (null === $parent) {
$badParents[] = sprintf('row %d: %s "%s" not found', $line + 2, $col, $val);
continue;
}
$npc->$setter($parent);
}
}
if ($badParents) {
$io->warning('Some Father/Mother values could not be resolved:');
$io->listing($badParents);
}
$purged = 0;
if ($input->getOption('purge')) {
foreach ($existing as $key => $npc) {
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Command;
use App\Repository\NpcRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:npc:strip-parent-text',
description: 'Remove "son/daughter/child of X [and Y]" from the Relationships / Notes text (parents now live in their own father/mother fields).',
)]
class StripParentTextCommand extends Command
{
/**
* Matches "Son of X", "Daughter of X and Y", "Youngest child of X and Y".
* Keeps "grandson of ...", "father of ...", "daughter-in-law of ..." intact.
*/
private const RE = "/(?:youngest\\s+)?(?<!grand)(?:son|daughter|child)\\s+of\\s+\\p{Lu}[\\p{L}'-]+(?:\\s+and\\s+\\p{Lu}[\\p{L}'-]+)?/iu";
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NpcRepository $npcs,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show changes without writing');
}
/** Strip the parent clause and tidy up leftover punctuation. Returns null if nothing meaningful remains. */
public static function clean(?string $text): ?string
{
if (null === $text || '' === trim($text)) {
return $text;
}
$out = preg_replace(self::RE, '', $text);
$out = preg_replace('/\s*[;,]\s*[;,]\s*/', ', ', (string) $out); // ", ," -> ", "
$out = preg_replace('/^\s*[;,]\s*/', '', (string) $out); // leading punctuation
$out = preg_replace('/\s*[;,]\s*$/', '', (string) $out); // trailing punctuation
$out = trim(preg_replace('/\s{2,}/', ' ', (string) $out));
if ('' === $out) {
return null;
}
return mb_strtoupper(mb_substr($out, 0, 1)).mb_substr($out, 1);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$rows = [];
$changed = 0;
foreach ($this->npcs->findAll() as $npc) {
$before = $npc->getRelationships();
$after = self::clean($before);
if ($before === $after) {
continue;
}
++$changed;
$rows[] = [$npc->getName(), (string) $before, $after ?? '(cleared)'];
if (!$dryRun) {
$npc->setRelationships($after);
}
}
if (!$dryRun) {
$this->em->flush();
}
$io->table(['NPC', 'Before', 'After'], $rows);
$io->success(sprintf('%s: %d of %d NPCs updated.', $dryRun ? 'Dry run' : 'Done', $changed, \count($this->npcs->findAll())));
return Command::SUCCESS;
}
}