158 lines
5.5 KiB
JavaScript
158 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* tag-and-dedup-rules.js
|
|
*
|
|
* 1. Re-tags "sanitized" rules with their real source (Core Rulebook).
|
|
* 2. Deduplicates rules with the same normalised title, keeping the
|
|
* version from the most authoritative source.
|
|
*
|
|
* Source priority (highest first):
|
|
* Core Rulebook > First Founding > Rites of Battle >
|
|
* Mark of the Xenos > Honour the Chapter > fandom > sanitized
|
|
*
|
|
* Usage:
|
|
* node scripts/tag-and-dedup-rules.js
|
|
* node scripts/tag-and-dedup-rules.js --dry-run (print, don't save)
|
|
*/
|
|
|
|
const { rulesHelpers, logToFile } = require('../database/mariadb');
|
|
const mysql = require('mysql2/promise');
|
|
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
|
|
const SOURCE_PRIORITY = [
|
|
'Core Rulebook',
|
|
'First Founding',
|
|
'Rites of Battle',
|
|
'Mark of the Xenos',
|
|
'Honour the Chapter',
|
|
'https://40k-rpg-ffg.fandom.com',
|
|
'fandom',
|
|
'sanitized',
|
|
];
|
|
|
|
function priority(source) {
|
|
const i = SOURCE_PRIORITY.indexOf(source);
|
|
return i === -1 ? SOURCE_PRIORITY.length : i;
|
|
}
|
|
|
|
function normalizeTitle(t) {
|
|
return (t || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9 ]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
async function getPool() {
|
|
if (!process.env.DB_PASSWORD) {
|
|
throw new Error('DB_PASSWORD must be set in database/.env or .env');
|
|
}
|
|
return mysql.createPool({
|
|
host: process.env.DB_HOST || '192.168.1.113',
|
|
port: Number(process.env.DB_PORT || 3307),
|
|
user: process.env.DB_USER || 'deathwatch',
|
|
password: process.env.DB_PASSWORD,
|
|
database: process.env.DB_NAME || 'deathwatch',
|
|
waitForConnections: true,
|
|
connectionLimit: 5,
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const pool = await getPool();
|
|
|
|
// ── 1. Load all rules ────────────────────────────────────────────────────
|
|
console.log('Loading all rules…');
|
|
const [rows] = await pool.execute('SELECT id, rule_id, title, source, source_abbr, category, LENGTH(content) as clen FROM rules');
|
|
console.log(` ${rows.length} total rules`);
|
|
|
|
// ── 2. Re-tag sanitized → Core Rulebook ──────────────────────────────────
|
|
const sanitized = rows.filter(r => r.source === 'sanitized');
|
|
console.log(`\n[1] Re-tagging ${sanitized.length} "sanitized" rules → Core Rulebook`);
|
|
|
|
if (!DRY_RUN && sanitized.length > 0) {
|
|
await pool.execute(
|
|
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source='sanitized'`
|
|
);
|
|
console.log(` ✓ Updated ${sanitized.length} rows`);
|
|
// Refresh
|
|
sanitized.forEach(r => { r.source = 'Core Rulebook'; r.source_abbr = 'CR'; });
|
|
}
|
|
|
|
// ── 3. Find duplicates by normalised title ───────────────────────────────
|
|
console.log('\n[2] Detecting duplicate titles…');
|
|
|
|
const byNormTitle = {};
|
|
for (const r of rows) {
|
|
const key = normalizeTitle(r.title);
|
|
if (!key) continue;
|
|
if (!byNormTitle[key]) byNormTitle[key] = [];
|
|
// Update source after re-tag
|
|
if (r.source === 'sanitized') r.source = 'Core Rulebook';
|
|
byNormTitle[key].push(r);
|
|
}
|
|
|
|
const dupGroups = Object.values(byNormTitle).filter(g => g.length > 1);
|
|
console.log(` Found ${dupGroups.length} duplicate groups`);
|
|
|
|
let deleted = 0;
|
|
const toDelete = [];
|
|
|
|
for (const group of dupGroups) {
|
|
// Sort by priority (best source first), then by content length (longer = better)
|
|
group.sort((a, b) => {
|
|
const pd = priority(a.source) - priority(b.source);
|
|
return pd !== 0 ? pd : b.clen - a.clen;
|
|
});
|
|
|
|
const keep = group[0];
|
|
const remove = group.slice(1);
|
|
|
|
if (DRY_RUN && deleted < 20) {
|
|
console.log(` KEEP [${keep.source}] "${keep.title}" (${keep.clen} chars)`);
|
|
remove.forEach(r => console.log(` DEL [${r.source}] "${r.title}" (${r.clen} chars)`));
|
|
}
|
|
|
|
remove.forEach(r => toDelete.push(r.id));
|
|
deleted += remove.length;
|
|
}
|
|
|
|
console.log(` Will delete ${toDelete.length} duplicates`);
|
|
|
|
if (!DRY_RUN && toDelete.length > 0) {
|
|
// Delete in batches of 500
|
|
for (let i = 0; i < toDelete.length; i += 500) {
|
|
const batch = toDelete.slice(i, i + 500);
|
|
const placeholders = batch.map(() => '?').join(',');
|
|
await pool.execute(`DELETE FROM rules WHERE id IN (${placeholders})`, batch);
|
|
}
|
|
console.log(` ✓ Deleted ${toDelete.length} duplicate rules`);
|
|
}
|
|
|
|
// ── 4. Fix bad categories on fandom/url sources ──────────────────────────
|
|
console.log('\n[3] Fixing source labels for fandom/url entries…');
|
|
if (!DRY_RUN) {
|
|
const [r1] = await pool.execute(
|
|
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source LIKE '%fandom%' OR source LIKE '%40k-rpg%'`
|
|
);
|
|
console.log(` ✓ Fixed ${r1.affectedRows} fandom entries`);
|
|
}
|
|
|
|
// ── 5. Final stats ───────────────────────────────────────────────────────
|
|
const [stats] = await pool.execute(`
|
|
SELECT source, COUNT(*) as cnt FROM rules GROUP BY source ORDER BY cnt DESC
|
|
`);
|
|
console.log('\n── Final source distribution:');
|
|
for (const s of stats) {
|
|
console.log(` ${String(s.cnt).padStart(5)} ${s.source}`);
|
|
}
|
|
|
|
const [total] = await pool.execute('SELECT COUNT(*) as cnt FROM rules');
|
|
console.log(`\nTotal: ${total[0].cnt} rules`);
|
|
|
|
await pool.end();
|
|
}
|
|
|
|
main().catch(e => { console.error(e); process.exit(1); });
|