diff --git a/assets/app.js b/assets/app.js index 3347505..4d670bf 100644 --- a/assets/app.js +++ b/assets/app.js @@ -13,14 +13,48 @@ import DataTable from 'datatables.net-bs5'; window.bootstrap = bootstrap; -// Turn any into a DataTable. Opt out per-table with -// data-datatable="false"; pass options as a JSON object in data-datatable-options. +const esc = (v) => { + if (v === null || v === undefined) return ''; + return String(v).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +}; + +// Build a DataTables column definition from a
in the table head. +// data-data key in the row object (required for server-side tables) +// data-orderable "false" to disable sorting on the column +// data-format "link" | "truncate" | (default: plain escaped text) +// data-row-url (on the ) URL template with __ID__ for "link" columns +// NB: "format" not "render" - DataTables itself consumes a data-render attribute. +function columnFromTh(th, rowUrl) { + const key = th.dataset.data; + const col = { data: key || null, orderable: th.dataset.orderable !== 'false' }; + + if (th.dataset.format === 'link' && rowUrl) { + col.render = (val, type, row) => { + if (type !== 'display') return val ?? ''; + return `${esc(val)}`; + }; + } else if (th.dataset.format === 'truncate') { + col.render = (val, type) => { + if (type !== 'display') return val ?? ''; + const s = val ? String(val) : ''; + const short = s.length > 70 ? s.slice(0, 70) + '…' : s; + return `${esc(short)}`; + }; + } else { + col.render = (val, type) => (type === 'display' ? esc(val) : (val ?? '')); + } + return col; +} + function initDataTables(root = document) { root.querySelectorAll('table.datatable').forEach((table) => { if (table.dataset.datatable === 'false' || DataTable.isDataTable(table)) { return; } - let options = { pageLength: 25, order: [] }; + + let options = { pageLength: 25, order: [], responsive: false }; if (table.dataset.datatableOptions) { try { options = { ...options, ...JSON.parse(table.dataset.datatableOptions) }; @@ -28,6 +62,20 @@ function initDataTables(root = document) { console.error('Invalid data-datatable-options on', table, e); } } + + // Server-side mode: the backend does the paging, sorting and searching. + if (table.dataset.datatableSrc) { + const ths = [...table.querySelectorAll('thead th')]; + options = { + ...options, + serverSide: true, + processing: true, + ajax: { url: table.dataset.datatableSrc }, + columns: ths.map((th) => columnFromTh(th, table.dataset.rowUrl)), + order: options.order.length ? options.order : [[0, 'asc']], + }; + } + new DataTable(table, options); }); } diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..0b0ddbe 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,6 @@ twig: file_name_pattern: '*.twig' + form_themes: ['bootstrap_5_layout.html.twig'] when@test: twig: diff --git a/data/npcs.csv b/data/npcs.csv new file mode 100644 index 0000000..cf2b457 --- /dev/null +++ b/data/npcs.csv @@ -0,0 +1,181 @@ +Family,Name,Age,Race,Occupation,Relationships / Notes,Village Status,Blood Cart Involvement,Mystery Importance +Voss,Garrick Voss,61,Human,Council elder,"Husband of Elira, father of Maren and Tobin",Established villager,Council oversight,High +Voss,Elira Voss,58,Human,Housekeeper / herbalist,Wife of Garrick,Established villager,None,Low +Voss,Maren Voss,34,Human,Farmer,Daughter of Garrick and Elira,Established villager,Occasional donor,Low +Voss,Tobin Voss,36,Human,Carpenter,"Son of Garrick and Elira, husband of Lena",Established villager,Occasional donor,Low +Voss,Lena Voss,34,Human,Carpenter,Wife of Tobin,Established villager,Occasional donor,Low +Voss,Mira Voss,7,Human,Child,Daughter of Tobin and Lena,Young villager,None,Low +Voss,Sten Voss,5,Human,Child,Son of Maren; grandson of Garrick and Elira,Young villager,None,Low +Alden,Hugo Alden,52,Human,Farmer,"Husband of Mira, father of Joren, Elsa, Perrin",Established villager,Donor,Low +Alden,Mira Alden,49,Human,Farmer,Wife of Hugo,Established villager,Donor,Low +Alden,Joren Alden,22,Human,Farmer,Son of Hugo and Mira,Established villager,Donor,Low +Alden,Elsa Alden,19,Human,Weaver apprentice,Daughter of Hugo and Mira,Established villager,Donor,Low +Alden,Perrin Alden,15,Human,Farm apprentice,Youngest child of Hugo and Mira,Newly eligible donor,Donor,Low +Green,Matthias Green,57,Human,Farmer,Husband of Sella,Established villager,Donor,Low +Green,Sella Green,55,Human,Farmer,Wife of Matthias,Established villager,Donor,Low +Green,Corin Green,27,Human,Farmer,Son of Matthias and Sella,Established villager,Donor,Low +Green,Nessa Green,24,Human,Miller apprentice,Daughter of Matthias and Sella,Established villager,Donor,Low +Green,Willem Green,17,Human,Farmhand,Son of Matthias and Sella,Established villager,Donor,Low +Fen,Orrin Fen,50,Human,Farmer,Husband of Talia,Established villager,Donor,Low +Fen,Talia Fen,47,Human,Farmer,Wife of Orrin,Established villager,Donor,Low +Fen,Rusk Fen,25,Human,Farmer,Son of Orrin and Talia,Established villager,Donor,Low +Fen,Ilyra Fen,20,Human,Healer apprentice,Daughter of Orrin and Talia,Established villager,Donor,Medium +Fen,Bram Fen,14,Human,Shepherd apprentice,Son of Orrin and Talia,Young villager,None,Low +Barrow,Edric Barrow,54,Human,Livestock keeper,Husband of Marta,Established villager,Donor,Low +Barrow,Marta Barrow,51,Human,Dairy worker,Wife of Edric,Established villager,Donor,Low +Barrow,Finn Barrow,29,Human,Livestock keeper,Son of Edric and Marta,Established villager,Donor,Low +Barrow,Anya Barrow,26,Human,Dairy worker,Daughter-in-law of Edric and Marta,Established villager,Donor,Low +Barrow,Pia Barrow,8,Human,Child,Daughter of Finn and Anya,Young villager,None,Low +Barrow,Cob Barrow,4,Human,Child,"Son of Finn and Anya, younger brother of Pia",Young villager,None,Low +Blackthorn,Ronan Blackthorn,46,Human,Hunter,Husband of Maeve,Established villager,Donor,Low +Blackthorn,Maeve Blackthorn,43,Human,Hunter / trapper,Wife of Ronan,Established villager,Donor,Low +Blackthorn,Kellan Blackthorn,21,Human,Hunter apprentice,Son of Ronan and Maeve,Established villager,Donor,Low +Blackthorn,Suri Blackthorn,18,Human,Trapper apprentice,Daughter of Ronan and Maeve,Established villager,Donor,Medium +Blackthorn,Dain Blackthorn,11,Human,Child,Son of Ronan and Maeve,Young villager,None,Low +Blackthorn,Wynn Blackthorn,7,Human,Child,"Daughter of Ronan and Maeve, younger sister of Dain",Young villager,None,Low +Cooper,Bastian Cooper,59,Human,Cooper / blood-cart driver,"Husband of Nora, long-time cart driver",Established villager,Blood-cart driver,High +Cooper,Nora Cooper,56,Human,Cooper,Wife of Bastian,Established villager,None,Low +Cooper,Evan Cooper,31,Human,Cooper,Son of Bastian and Nora,Established villager,Donor,Low +Cooper,Jessa Cooper,28,Human,Cooper,Daughter-in-law of Bastian and Nora,Established villager,Donor,Low +Cooper,Milo Cooper,6,Human,Child,Son of Evan and Jessa,Young villager,None,Low +Cooper,Tam Cooper,3,Human,Child,"Daughter of Evan and Jessa, younger sister of Milo",Young villager,None,Low +Miller,Halric Miller,63,Human,Miller,Husband of Dora,Established villager,Donor,Low +Miller,Dora Miller,60,Human,Miller's wife,Wife of Halric,Established villager,Donor,Low +Miller,Tess Miller,33,Human,Mill assistant,Daughter of Halric and Dora,Established villager,Donor,Low +Miller,Jon Miller,30,Human,Mill worker,Son of Halric and Dora,Established villager,Donor,Low +Miller,Pavel Miller,16,Human,Mill apprentice,"Apprentice from nearby village, recently completed local ceremony",Adopted villager,Donor,Medium +Miller,Pell Miller,4,Human,Child,Son of Jon; grandson of Halric and Dora,Young villager,None,Low +Ironhand,Durn Ironhand,84,Dwarf,Blacksmith,"Husband of Kara, family head",Established villager,Donor,Low +Ironhand,Kara Ironhand,79,Dwarf,Armorer,Wife of Durn,Established villager,Donor,Low +Ironhand,Borin Ironhand,46,Dwarf,Blacksmith,Son of Durn and Kara,Established villager,Donor,Low +Ironhand,Mira Ironhand,43,Dwarf,Metalworker,Daughter-in-law of Durn and Kara,Established villager,Donor,Low +Ironhand,Tomas Ironhand,17,Dwarf,Blacksmith apprentice,Son of Borin and Mira,Established villager,Donor,Low +Ironhand,Vidra Ironhand,12,Dwarf,Child,"Daughter of Borin and Mira, younger sister of Tomas",Young villager,None,Low +Willow,Rhea Willow,48,Human,Weaver,Wife of Soren,Established villager,Donor,Low +Willow,Soren Willow,50,Human,Dyer,Husband of Rhea,Established villager,Donor,Low +Willow,Mina Willow,23,Human,Weaver,Daughter of Rhea and Soren,Established villager,Donor,Low +Willow,Jace Willow,20,Human,Weaver apprentice,Son of Rhea and Soren,Established villager,Donor,Low +Willow,Elin Willow,15,Human,Spinner apprentice,Daughter of Rhea and Soren,Newly eligible donor,Donor,Low +Oakridge,Harlan Oakridge,55,Human,Woodcutter,Husband of Bess,Established villager,Donor,Low +Oakridge,Bess Oakridge,53,Human,Woodcutter,Wife of Harlan,Established villager,Donor,Low +Oakridge,Niall Oakridge,26,Human,Woodcutter,Son of Harlan and Bess,Established villager,Donor,Low +Oakridge,Rin Oakridge,24,Human,Carpenter apprentice,Daughter of Harlan and Bess,Established villager,Donor,Low +Oakridge,Odo Oakridge,13,Human,Wood gatherer,Son of Harlan and Bess,Young villager,None,Low +Wood,Cedric Wood,49,Human,Carpenter,Husband of Mara,Established villager,Donor,Low +Wood,Mara Wood,46,Human,Carpenter,Wife of Cedric,Established villager,Donor,Low +Wood,Tarin Wood,19,Human,Carpenter apprentice,Son of Cedric and Mara,Established villager,Donor,Low +Wood,Lio Wood,16,Human,Carpenter apprentice,Apprentice from another village,Adopted villager,Donor,Low +Stone,Brannik Stone,58,Dwarf,Mason,Husband of Edda,Established villager,Donor,Low +Stone,Edda Stone,55,Dwarf,Mason,Wife of Brannik,Established villager,Donor,Low +Stone,Korin Stone,29,Dwarf,Mason,Son of Brannik and Edda,Established villager,Donor,Low +Stone,Vara Stone,25,Dwarf,Stoneworker,Daughter-in-law of Brannik and Edda,Established villager,Donor,Low +Stone,Niko Stone,12,Dwarf,Child,Son of Korin and Vara,Young villager,None,Low +Stone,Dorn Stone,8,Dwarf,Child,"Son of Korin and Vara, younger brother of Niko",Young villager,None,Low +Tanner,Gideon Tanner,44,Human,Tanner,Husband of Ruth,Established villager,Donor,Low +Tanner,Ruth Tanner,42,Human,Tanner,Wife of Gideon,Established villager,Donor,Low +Tanner,Cal Tanner,18,Human,Tanner apprentice,Son of Gideon and Ruth,Established villager,Donor,Low +Tanner,Iris Tanner,14,Human,Child,Daughter of Gideon and Ruth,Young villager,None,Low +Tanner,Hob Tanner,8,Human,Child,"Son of Gideon and Ruth, younger brother of Iris",Young villager,None,Low +Cobb,Merrin Cobb,51,Human,Shoemaker,Husband of Agnes,Established villager,Donor,Low +Cobb,Agnes Cobb,48,Human,Shoemaker,Wife of Merrin,Established villager,Donor,Low +Cobb,Lukas Cobb,21,Human,Shoemaker apprentice,Son of Merrin and Agnes,Established villager,Donor,Low +Cobb,Nell Cobb,10,Human,Child,Daughter of Merrin and Agnes,Young villager,None,Low +Brewster,Oswin Brewster,62,Human,Brewer,Husband of Hettie,Established villager,Donor,Low +Brewster,Hettie Brewster,59,Human,Tavern keeper,Wife of Oswin,Established villager,Donor,Low +Brewster,Ralf Brewster,35,Human,Brewer,Son of Oswin and Hettie,Established villager,Donor,Low +Brewster,Mara Brewster,31,Human,Tavern worker,Daughter-in-law of Oswin and Hettie,Established villager,Donor,Low +Brewster,Pip Brewster,7,Human,Child,Child of Ralf and Mara,Young villager,None,Low +Brewster,Nib Brewster,4,Human,Child,"Child of Ralf and Mara, younger sibling of Pip",Young villager,None,Low +Hearth,Jonas Hearth,57,Human,Innkeeper,Husband of Faye,Established villager,Donor,Low +Hearth,Faye Hearth,54,Human,Innkeeper,Wife of Jonas,Established villager,Donor,Low +Hearth,Cora Hearth,25,Human,Inn worker,Daughter of Jonas and Faye,Established villager,Donor,Low +Hearth,Eli Hearth,22,Human,Stable hand,Son of Jonas and Faye,Established villager,Donor,Low +Hearth,May Hearth,16,Human,Inn apprentice,Daughter of Jonas and Faye,Established villager,Donor,Low +Temple,Father Aldus,68,Human,Priest,"Village priest, trusted elder",Established villager,Donor,Medium +Temple,Mara Temple,64,Human,Temple caretaker,Sister of Father Aldus,Established villager,Donor,Medium +Temple,Jonah Temple,29,Human,Acolyte,Son of Mara,Established villager,Donor,Medium +Temple,Lysa Temple,21,Human,Acolyte,Daughter of Mara,Established villager,Donor,Medium +Wooler,Tomas Wooler,53,Human,Shepherd,Husband of Greta,Established villager,Donor,Low +Wooler,Greta Wooler,50,Human,Spinner,Wife of Tomas,Established villager,Donor,Low +Wooler,Merek Wooler,27,Human,Shepherd,Son of Tomas and Greta,Established villager,Donor,Low +Wooler,Sana Wooler,24,Human,Spinner,Daughter of Tomas and Greta,Established villager,Donor,Low +Wooler,Pella Wooler,9,Human,Child,Daughter of Merek,Young villager,None,Low +Wooler,Bede Wooler,6,Human,Child,"Son of Merek, younger brother of Pella",Young villager,None,Low +Bell,Hugh Bell,47,Human,Baker,Husband of Marta,Established villager,Donor,Low +Bell,Marta Bell,44,Human,Baker,Wife of Hugh,Established villager,Donor,Low +Bell,Ned Bell,19,Human,Baker apprentice,Son of Hugh and Marta,Established villager,Donor,Low +Bell,Elsie Bell,13,Human,Child,Daughter of Hugh and Marta,Young villager,None,Low +Clay,Orin Clay,45,Human,Potter,Husband of Dessa,Established villager,Donor,Low +Clay,Dessa Clay,42,Human,Potter,Wife of Orin,Established villager,Donor,Low +Clay,Fenn Clay,17,Human,Potter apprentice,Son of Orin and Dessa,Established villager,Donor,Low +Clay,Til Clay,10,Human,Child,"Daughter of Orin and Dessa, younger sister of Fenn",Young villager,None,Low +Fletcher,Arlen Fletcher,39,Human,Fletcher,Husband of Tessa,Established villager,Donor,Low +Fletcher,Tessa Fletcher,37,Human,Fletcher assistant,Wife of Arlen,Established villager,Donor,Low +Fletcher,Kip Fletcher,15,Human,Fletcher apprentice,Son of Arlen and Tessa,Newly eligible donor,Donor,Low +Fletcher,Doran Fletcher,8,Human,Child,"Son of Arlen and Tessa, younger brother of Kip",Young villager,None,Low +Reed,Jasper Reed,48,Human,Fisher,Husband of Mina,Established villager,Donor,Low +Reed,Mina Reed,45,Human,Fisher,Wife of Jasper,Established villager,Donor,Low +Reed,Tom Reed,22,Human,Fisher,Son of Jasper and Mina,Established villager,Donor,Low +Reed,Nia Reed,18,Human,Fisher apprentice,Daughter of Jasper and Mina,Established villager,Donor,Low +Marr,Gareth Marr,50,Human,Guard,Husband of Elena,Established villager,Donor,Medium +Marr,Elena Marr,47,Human,Guard,Wife of Gareth,Established villager,Donor,Medium +Marr,Rowan Marr,23,Human,Guard,Son of Gareth and Elena,Established villager,Donor,Medium +Marr,Della Marr,20,Human,Guard apprentice,Daughter of Gareth and Elena,Established villager,Donor,Medium +Wheelwright,Roderick Wheelwright,56,Human,Wheelwright,"Widower, family head",Established villager,Donor,Low +Wheelwright,Sanna Wheelwright,53,Human,Wheelwright assistant,Sister of Roderick,Established villager,Donor,Low +Wheelwright,Bert Wheelwright,28,Human,Wheelwright apprentice,Nephew of Roderick,Established villager,Donor,Low +Redbrook,Hester Redbrook,60,Human,Blood collector,Senior blood-collection organizer,Established villager,Blood collector,High +Redbrook,Colm Redbrook,57,Human,Blood collector,Husband of Hester,Established villager,Blood collector,High +Redbrook,Jory Redbrook,32,Human,Blood collector,Son of Hester and Colm,Established villager,Blood collector,High +Redbrook,Mara Redbrook,29,Human,Blood collector,Daughter-in-law of Hester and Colm,Established villager,Blood collector,High +Redbrook,Sable Redbrook,6,Human,Child,Daughter of Jory and Mara; growing up among the blood collectors,Young villager,None,Low +Fairweather,Edwin Fairweather,52,Human,General storekeeper,Husband of Clara,Established villager,Donor,Low +Fairweather,Clara Fairweather,49,Human,Shopkeeper,Wife of Edwin,Established villager,Donor,Low +Fairweather,Nolan Fairweather,23,Human,Shop assistant,Son of Edwin and Clara,Established villager,Donor,Low +Fairweather,Vera Fairweather,18,Human,Shop apprentice,Daughter of Edwin and Clara,Established villager,Donor,Low +Thorn,Bramble Thorn,45,Halfling,Beekeeper,Husband of Poppy,Established villager,Donor,Low +Thorn,Poppy Thorn,42,Halfling,Beekeeper,Wife of Bramble,Established villager,Donor,Low +Thorn,Wren Thorn,19,Halfling,Beekeeper apprentice,Child of Bramble and Poppy,Established villager,Donor,Low +Thorn,Bryony Thorn,8,Halfling,Child,"Daughter of Bramble and Poppy, younger sister of Wren",Young villager,None,Low +Vale,Eamon Vale,41,Human,Farmer / heir,"The inheritance recipient, recently encountered Aldren",Established villager,Donor,Critical +Vale,Maris Vale,39,Human,Housekeeper,"Partner of Eamon, currently caring for household",Established villager,Donor,Medium +Vale,Tilda Vale,17,Human,Apprentice,Daughter of Eamon and Maris,Established villager,Donor,Low +Vale,Rory Vale,12,Human,Child,Son of Eamon and Maris,Young villager,None,Low +Vale,Olda Vale,73,Human,Retired farmer,Eamon's mother,Established villager,Donor,Medium +Vale,Wick Vale,6,Human,Child,Youngest child of Eamon and Maris,Young villager,None,Low +Drift,Celyn Drift,33,Half-Elf,Dyer / apprentice,"Moved from another village, completed local ceremony",Adopted villager,Donor,Medium +Drift,Marlo Drift,35,Human,Cooper apprentice,"Moved from another village, completed local ceremony",Adopted villager,Donor,Low +Drift,Nessa Drift,16,Half-Elf,Apprentice,"Child of Celyn, recently eligible donor",Adopted villager,Donor,Low +Hill,Garran Hill,38,Human,Farmer,Husband of Sera,Established villager,Donor,Low +Hill,Sera Hill,36,Human,Seamstress,Wife of Garran,Established villager,Donor,Low +Hill,Oren Hill,14,Human,Apprentice,Son of Garran and Sera,Young villager,None,Low +Hill,Bryn Hill,6,Human,Child,"Son of Garran and Sera, younger brother of Oren",Young villager,None,Low +Pine,Alda Pine,30,Human,Hunter,Partner of Marek,Established villager,Donor,Low +Pine,Marek Pine,32,Human,Woodworker,Partner of Alda,Established villager,Donor,Low +Pine,Rin Pine,11,Human,Child,Child of Alda and Marek,Young villager,None,Low +Pine,Cael Pine,4,Human,Child,"Child of Alda and Marek, younger sibling of Rin",Young villager,None,Low +Stonebrook,Kellen Stonebrook,28,Human,Mason apprentice,Moved from nearby village,Adopted villager,Donor,Low +Stonebrook,Fara Stonebrook,26,Human,Baker assistant,Partner of Kellen,Adopted villager,Donor,Low +Stonebrook,Miri Stonebrook,5,Human,Child,Child of Kellen and Fara,Young villager,None,Low +Stonebrook,Tovin Stonebrook,2,Human,Child,"Son of Kellen and Fara, younger brother of Miri",Young villager,None,Low +Carter,Alric Carter,45,Human,Farrier / cart repairer,"Lives alone, works closely with Wheelwright family",Established villager,Donor,Low +Carter,Brena Carter,43,Human,Farrier assistant,Sister of Alric,Established villager,Donor,Low +Carter,Milo Carter,18,Human,Farrier apprentice,Nephew of Alric,Established villager,Donor,Low +Orchard,Della Orchard,52,Human,Orchard keeper,"Widow, manages communal fruit trees",Established villager,Donor,Low +Orchard,Seth Orchard,24,Human,Orchard worker,Son of Della,Established villager,Donor,Low +Orchard,Nora Orchard,16,Human,Orchard apprentice,Daughter of Della,Established villager,Donor,Low +Seam,Hilda Seam,59,Human,Seamstress,"Lives near village square, unofficial gossip hub",Established villager,Donor,Medium +Seam,Kara Seam,27,Human,Seamstress apprentice,Daughter of Hilda,Established villager,Donor,Low +Seam,Jory Seam,13,Human,Child,Son of Hilda,Young villager,None,Low +Ash,Corvin Ash,40,Human,Charcoal burner,Husband of Nella,Established villager,Donor,Low +Ash,Nella Ash,38,Human,Charcoal burner,Wife of Corvin,Established villager,Donor,Low +Ash,Perrin Ash,17,Human,Charcoal apprentice,Son of Corvin and Nella,Established villager,Donor,Low +Ash,Lark Ash,9,Human,Child,"Daughter of Corvin and Nella, younger sister of Perrin",Young villager,None,Low +Bellflower,Sella Bellflower,34,Human,Midwife,"Trusted by many families, knows births and deaths",Established villager,Donor,Medium +Bellflower,Aric Bellflower,36,Human,Field worker,Husband of Sella,Established villager,Donor,Low +Bellflower,Mara Bellflower,12,Human,Child,Daughter of Sella and Aric,Young villager,None,Low +Bellflower,Pim Bellflower,5,Human,Child,"Son of Sella and Aric, younger brother of Mara",Young villager,None,Low +Rook,Tarin Rook,48,Human,Messenger / trader,"Travels between villages, knows regional rumors",Established villager,Donor,High +Rook,Elsa Rook,45,Human,Inn assistant,Wife of Tarin,Established villager,Donor,Low +Rook,Jace Rook,19,Human,Messenger apprentice,Son of Tarin and Elsa,Established villager,Donor,Medium +Rook,Mira Rook,9,Human,Child,Daughter of Tarin and Elsa,Young villager,None,Low diff --git a/migrations/Version20260906125931.php b/migrations/Version20260906125931.php new file mode 100644 index 0000000..800bba6 --- /dev/null +++ b/migrations/Version20260906125931.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE npc (id INT AUTO_INCREMENT NOT NULL, family VARCHAR(120) NOT NULL, name VARCHAR(160) NOT NULL, age SMALLINT DEFAULT NULL, race VARCHAR(255) NOT NULL, occupation VARCHAR(160) DEFAULT NULL, relationships LONGTEXT DEFAULT NULL, village_status VARCHAR(255) NOT NULL, blood_cart_involvement VARCHAR(255) NOT NULL, mystery_importance VARCHAR(255) NOT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_npc_family_name (family, name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE npc'); + } +} diff --git a/src/Command/ImportNpcsCommand.php b/src/Command/ImportNpcsCommand.php new file mode 100644 index 0000000..dc59416 --- /dev/null +++ b/src/Command/ImportNpcsCommand.php @@ -0,0 +1,196 @@ + setter-ish key. */ + private const COLUMNS = [ + 'Family', 'Name', 'Age', 'Race', 'Occupation', + 'Relationships / Notes', 'Village Status', 'Blood Cart Involvement', 'Mystery Importance', + ]; + + public function __construct( + private readonly EntityManagerInterface $em, + private readonly NpcRepository $npcs, + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDir, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('file', InputArgument::OPTIONAL, 'CSV path', 'data/npcs.csv') + ->addOption('purge', null, InputOption::VALUE_NONE, 'Delete NPCs not present in the file') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would change without writing'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + + $path = $input->getArgument('file'); + if (!is_file($path) && is_file($this->projectDir.'/'.$path)) { + $path = $this->projectDir.'/'.$path; + } + if (!is_readable($path)) { + $io->error(sprintf('File not readable: %s', $path)); + + return Command::FAILURE; + } + + $rows = $this->readCsv($path, $io); + if (null === $rows) { + return Command::FAILURE; + } + + // Validate every enum value up front so we fail cleanly, not half-way. + $unknown = []; + foreach ($rows as $line => $r) { + foreach ([ + [Race::class, $r['Race']], + [VillageStatus::class, $r['Village Status']], + [BloodCartInvolvement::class, $r['Blood Cart Involvement']], + [MysteryImportance::class, $r['Mystery Importance']], + ] as [$enum, $value]) { + if (null === $enum::tryFrom((string) $value)) { + $unknown[] = sprintf('row %d: %s has no case for "%s"', $line + 2, $enum, $value); + } + } + } + if ($unknown) { + $io->error('Unknown enum values - add the case(s) or fix the file:'); + $io->listing($unknown); + + return Command::FAILURE; + } + + $existing = []; + foreach ($this->npcs->findAll() as $npc) { + $existing[$this->key($npc->getFamily(), $npc->getName())] = $npc; + } + + $created = $updated = 0; + $seen = []; + foreach ($rows as $r) { + $key = $this->key($r['Family'], $r['Name']); + $seen[$key] = true; + + $npc = $existing[$key] ?? null; + if (null === $npc) { + $npc = (new Npc())->setFamily($r['Family'])->setName($r['Name']); + if (!$dryRun) { + $this->em->persist($npc); + } + ++$created; + } else { + ++$updated; + } + + // NOTE: gameNotes is deliberately never written here. + $npc->setAge('' === $r['Age'] ? null : (int) $r['Age']) + ->setRace(Race::from($r['Race'])) + ->setOccupation($r['Occupation'] ?: null) + ->setRelationships($r['Relationships / Notes'] ?: null) + ->setVillageStatus(VillageStatus::from($r['Village Status'])) + ->setBloodCartInvolvement(BloodCartInvolvement::from($r['Blood Cart Involvement'])) + ->setMysteryImportance(MysteryImportance::from($r['Mystery Importance'])); + } + + $purged = 0; + if ($input->getOption('purge')) { + foreach ($existing as $key => $npc) { + if (!isset($seen[$key])) { + if (!$dryRun) { + $this->em->remove($npc); + } + ++$purged; + } + } + } + + if (!$dryRun) { + $this->em->flush(); + } + + $io->success(sprintf( + '%s: %d created, %d updated%s (%d rows in file).', + $dryRun ? 'Dry run' : 'Imported', + $created, + $updated, + $input->getOption('purge') ? ", {$purged} purged" : '', + \count($rows), + )); + + return Command::SUCCESS; + } + + /** + * @return list>|null + */ + private function readCsv(string $path, SymfonyStyle $io): ?array + { + $fh = fopen($path, 'r'); + $header = fgetcsv($fh, escape: ''); + if (false === $header) { + $io->error('Empty file.'); + fclose($fh); + + return null; + } + $header = array_map('trim', $header); + $missing = array_diff(self::COLUMNS, $header); + if ($missing) { + $io->error('Missing columns: '.implode(', ', $missing)); + fclose($fh); + + return null; + } + + $rows = []; + while (false !== ($line = fgetcsv($fh, escape: ''))) { + if (1 === \count($line) && (null === $line[0] || '' === trim((string) $line[0]))) { + continue; // blank line + } + $assoc = []; + foreach ($header as $i => $name) { + $assoc[$name] = trim((string) ($line[$i] ?? '')); + } + if ('' === $assoc['Family'] && '' === $assoc['Name']) { + continue; + } + $rows[] = $assoc; + } + fclose($fh); + + return $rows; + } + + private function key(string $family, string $name): string + { + return mb_strtolower(trim($family).'|'.trim($name)); + } +} diff --git a/src/Controller/NpcController.php b/src/Controller/NpcController.php index 102b9db..925d467 100644 --- a/src/Controller/NpcController.php +++ b/src/Controller/NpcController.php @@ -2,7 +2,13 @@ namespace App\Controller; +use App\Entity\Npc; +use App\Form\NpcType; +use App\Repository\NpcRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -10,9 +16,71 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('IS_AUTHENTICATED_FULLY')] class NpcController extends AbstractController { - #[Route('/npcs', name: 'app_npcs')] + #[Route('/npcs', name: 'app_npcs', methods: ['GET'])] public function index(): Response { return $this->render('npc/index.html.twig'); } + + /** + * Server-side processing endpoint for the DataTable on /npcs. + * https://datatables.net/manual/server-side + */ + #[Route('/npcs/data', name: 'app_npcs_data', methods: ['GET'])] + public function data(Request $request, NpcRepository $repo): JsonResponse + { + $q = $request->query; + $draw = (int) $q->get('draw', 1); + $start = (int) $q->get('start', 0); + $length = (int) $q->get('length', 25); + $search = (string) ($q->all('search')['value'] ?? ''); + + $columns = $q->all('columns'); + $order = $q->all('order')[0] ?? ['column' => 0, 'dir' => 'asc']; + $sortKey = $columns[(int) ($order['column'] ?? 0)]['data'] ?? 'family'; + $sortDir = (string) ($order['dir'] ?? 'asc'); + + $result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length); + + $data = array_map(static fn (Npc $n) => [ + 'id' => $n->getId(), + 'family' => $n->getFamily(), + 'name' => $n->getName(), + 'age' => $n->getAge(), + 'race' => $n->getRace()->value, + 'occupation' => $n->getOccupation(), + 'relationships' => $n->getRelationships(), + 'villageStatus' => $n->getVillageStatus()->value, + 'bloodCartInvolvement' => $n->getBloodCartInvolvement()->value, + 'mysteryImportance' => $n->getMysteryImportance()->value, + 'gameNotes' => $n->getGameNotes(), + 'updatedAt' => $n->getUpdatedAt()?->format('Y-m-d H:i'), + ], $result['data']); + + return $this->json([ + 'draw' => $draw, + 'recordsTotal' => $result['recordsTotal'], + 'recordsFiltered' => $result['recordsFiltered'], + 'data' => $data, + ]); + } + + #[Route('/npcs/{id}', name: 'app_npc_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] + public function edit(Request $request, Npc $npc, EntityManagerInterface $em): Response + { + $form = $this->createForm(NpcType::class, $npc); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + $this->addFlash('success', sprintf('Saved %s.', $npc->getName())); + + return $this->redirectToRoute('app_npc_edit', ['id' => $npc->getId()]); + } + + return $this->render('npc/edit.html.twig', [ + 'npc' => $npc, + 'form' => $form, + ]); + } } diff --git a/src/Entity/Npc.php b/src/Entity/Npc.php new file mode 100644 index 0000000..88a4c4e --- /dev/null +++ b/src/Entity/Npc.php @@ -0,0 +1,198 @@ +updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getFamily(): string + { + return $this->family; + } + + public function setFamily(string $family): static + { + $this->family = $family; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getAge(): ?int + { + return $this->age; + } + + public function setAge(?int $age): static + { + $this->age = $age; + + return $this; + } + + public function getRace(): Race + { + return $this->race; + } + + public function setRace(Race $race): static + { + $this->race = $race; + + return $this; + } + + public function getOccupation(): ?string + { + return $this->occupation; + } + + public function setOccupation(?string $occupation): static + { + $this->occupation = $occupation; + + return $this; + } + + public function getRelationships(): ?string + { + return $this->relationships; + } + + public function setRelationships(?string $relationships): static + { + $this->relationships = $relationships; + + return $this; + } + + public function getVillageStatus(): VillageStatus + { + return $this->villageStatus; + } + + public function setVillageStatus(VillageStatus $villageStatus): static + { + $this->villageStatus = $villageStatus; + + return $this; + } + + public function getBloodCartInvolvement(): BloodCartInvolvement + { + return $this->bloodCartInvolvement; + } + + public function setBloodCartInvolvement(BloodCartInvolvement $bloodCartInvolvement): static + { + $this->bloodCartInvolvement = $bloodCartInvolvement; + + return $this; + } + + public function getMysteryImportance(): MysteryImportance + { + return $this->mysteryImportance; + } + + public function setMysteryImportance(MysteryImportance $mysteryImportance): static + { + $this->mysteryImportance = $mysteryImportance; + + return $this; + } + + public function getGameNotes(): ?string + { + return $this->gameNotes; + } + + public function setGameNotes(?string $gameNotes): static + { + $this->gameNotes = $gameNotes; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Enum/BloodCartInvolvement.php b/src/Enum/BloodCartInvolvement.php new file mode 100644 index 0000000..11a268a --- /dev/null +++ b/src/Enum/BloodCartInvolvement.php @@ -0,0 +1,18 @@ +value; + } +} diff --git a/src/Enum/MysteryImportance.php b/src/Enum/MysteryImportance.php new file mode 100644 index 0000000..de40c5b --- /dev/null +++ b/src/Enum/MysteryImportance.php @@ -0,0 +1,16 @@ +value; + } +} diff --git a/src/Enum/Race.php b/src/Enum/Race.php new file mode 100644 index 0000000..045bbe7 --- /dev/null +++ b/src/Enum/Race.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/src/Enum/VillageStatus.php b/src/Enum/VillageStatus.php new file mode 100644 index 0000000..f193af3 --- /dev/null +++ b/src/Enum/VillageStatus.php @@ -0,0 +1,16 @@ +value; + } +} diff --git a/src/Form/NpcType.php b/src/Form/NpcType.php new file mode 100644 index 0000000..50859ae --- /dev/null +++ b/src/Form/NpcType.php @@ -0,0 +1,52 @@ + [ + 'class' => $class, + 'choice_label' => static fn ($case) => $case->value, + ]; + + $builder + ->add('family', TextType::class) + ->add('name', TextType::class) + ->add('age', IntegerType::class, ['required' => false]) + ->add('race', EnumType::class, $enum(Race::class)) + ->add('occupation', TextType::class, ['required' => false]) + ->add('relationships', TextareaType::class, [ + 'required' => false, + 'label' => 'Relationships / Notes', + 'attr' => ['rows' => 3], + ]) + ->add('villageStatus', EnumType::class, $enum(VillageStatus::class)) + ->add('bloodCartInvolvement', EnumType::class, $enum(BloodCartInvolvement::class)) + ->add('mysteryImportance', EnumType::class, $enum(MysteryImportance::class)) + ->add('gameNotes', TextareaType::class, [ + 'required' => false, + 'label' => 'Game notes', + 'attr' => ['rows' => 10, 'placeholder' => 'What happened with this NPC during play…'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => Npc::class]); + } +} diff --git a/src/Repository/NpcRepository.php b/src/Repository/NpcRepository.php new file mode 100644 index 0000000..92b0992 --- /dev/null +++ b/src/Repository/NpcRepository.php @@ -0,0 +1,81 @@ + + */ +class NpcRepository extends ServiceEntityRepository +{ + /** Fields the DataTable may sort on (column key => DQL field). */ + public const SORTABLE = [ + 'family' => 'n.family', + 'name' => 'n.name', + 'age' => 'n.age', + 'race' => 'n.race', + 'occupation' => 'n.occupation', + 'villageStatus' => 'n.villageStatus', + 'bloodCartInvolvement' => 'n.bloodCartInvolvement', + 'mysteryImportance' => 'n.mysteryImportance', + 'updatedAt' => 'n.updatedAt', + ]; + + /** Fields the global search box matches against (LIKE, OR-ed). */ + private const SEARCHABLE = [ + 'n.family', 'n.name', 'n.occupation', 'n.relationships', 'n.gameNotes', + 'n.race', 'n.villageStatus', 'n.bloodCartInvolvement', 'n.mysteryImportance', + ]; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Npc::class); + } + + /** + * Server-side data for DataTables. + * + * @return array{data: list, recordsTotal: int, recordsFiltered: int} + */ + public function findForDataTable( + ?string $search, + string $sortKey, + string $sortDir, + int $start, + int $length, + ): array { + $sortField = self::SORTABLE[$sortKey] ?? 'n.family'; + $sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC'; + $length = $length < 0 ? 200 : min($length, 200); + + $qb = $this->createQueryBuilder('n'); + + if (null !== $search && '' !== trim($search)) { + $ors = []; + foreach (self::SEARCHABLE as $i => $field) { + $ors[] = $field.' LIKE :s'.$i; + $qb->setParameter('s'.$i, '%'.trim($search).'%'); + } + $qb->andWhere('('.implode(' OR ', $ors).')'); + } + + $filtered = (int) (clone $qb)->select('COUNT(n.id)')->getQuery()->getSingleScalarResult(); + + $rows = $qb + ->orderBy($sortField, $sortDir) + ->addOrderBy('n.name', 'ASC') + ->setFirstResult(max(0, $start)) + ->setMaxResults($length) + ->getQuery() + ->getResult(); + + return [ + 'data' => $rows, + 'recordsTotal' => $this->count([]), + 'recordsFiltered' => $filtered, + ]; + } +} diff --git a/templates/npc/edit.html.twig b/templates/npc/edit.html.twig new file mode 100644 index 0000000..0f737e4 --- /dev/null +++ b/templates/npc/edit.html.twig @@ -0,0 +1,51 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ npc.name }} · NPC's · dmtools{% endblock %} + +{% block body %} +
+
+ ← All NPC's +

{{ npc.name }}

+
+ {% if npc.updatedAt %} + Updated {{ npc.updatedAt|date('Y-m-d H:i') }} + {% endif %} +
+ + {{ form_start(form) }} +
+
+
+
Details
+
+
+
{{ form_row(form.name) }}
+
{{ form_row(form.age) }}
+
+ {{ form_row(form.family) }} + {{ form_row(form.race) }} + {{ form_row(form.occupation) }} + {{ form_row(form.villageStatus) }} + {{ form_row(form.bloodCartInvolvement) }} + {{ form_row(form.mysteryImportance) }} + {{ form_row(form.relationships) }} +
+
+
+
+
+
Game notes
+
+ {{ form_row(form.gameNotes, {label: false}) }} +
+
+
+
+ +
+ + Cancel +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/npc/index.html.twig b/templates/npc/index.html.twig index 0a08c18..90acafe 100644 --- a/templates/npc/index.html.twig +++ b/templates/npc/index.html.twig @@ -3,9 +3,32 @@ {% block title %}NPC's · dmtools{% endblock %} {% block body %} - {{ include('_partials/page_placeholder.html.twig', { - title: "NPC's", - icon: '🧙', - blurb: 'Non-player characters will live here — searchable list with faction, location and notes. Data model to be designed.', - }) }} +
+

NPC's

+
+ +
+
+
+ + + + + + + + + + + + + + + +
NameFamilyAgeRaceOccupationVillage statusBlood cartMysteryGame notesUpdated
+ + {% endblock %}