#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const pdf = require('pdf-parse'); const Database = require('better-sqlite3'); const repoRoot = path.resolve(__dirname, '..'); const dbPath = path.join(repoRoot, 'database', 'sqlite', 'deathwatch.db'); const backupsDir = path.join(repoRoot, 'database', 'backups'); const defaultPdf = path.join(repoRoot, 'data', 'Deathwatch_Core_Rulebook_copy.pdf'); const rulesJson = path.join(repoRoot, 'database', 'rules', 'rules-database.json'); function ensureDir(dir){ if(!fs.existsSync(dir)) fs.mkdirSync(dir,{recursive:true}); } ensureDir(backupsDir); function nowTs(){ return new Date().toISOString().replace(/[:.]/g,'-'); } const pdfPath = process.argv[2] || defaultPdf; if(!fs.existsSync(dbPath)){ console.error('Cannot find DB at', dbPath); process.exit(1); } if(!fs.existsSync(pdfPath)){ console.error('Cannot find PDF at', pdfPath); process.exit(1); } if(!fs.existsSync(rulesJson)){ console.error('Cannot find rules JSON at', rulesJson); process.exit(1); } const db = new Database(dbPath); // Backup current rules table (safe guard) const backupFile = path.join(backupsDir, `rules-backup-before-pdf-import-whitelist-${nowTs()}.json`); try{ const rows = db.prepare('SELECT * FROM rules').all(); fs.writeFileSync(backupFile, JSON.stringify(rows,null,2)); console.log('Wrote rules backup to', backupFile); }catch(e){ console.error('Failed to backup rules table:', e); process.exit(1); } function cleanLine(s){ return (s||'').replace(/\r/g,'').replace(/[\u00A0]/g,' ').trim(); } function collapseText(s){ return (s||'').replace(/\n{2,}/g,'\n\n').replace(/[ \t]+/g,' ').trim(); } function joinHyphenation(text){ return String(text).replace(/-\n\s*/g,''); } function normalizeForMatch(s){ if(!s) return ''; // lower, remove punctuation, collapse spaces const t = String(s).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu,''); return t.replace(/[^a-z0-9\s]/g,'').replace(/\s+/g,' ').trim(); } function levenshtein(a,b){ // simple DP if(a===b) return 0; const m = a.length, n = b.length; if(m===0) return n; if(n===0) return m; const dp = Array(n+1).fill(0).map((_,i)=>i); for(let i=1;i<=m;i++){ let prev = dp[0]; dp[0]=i; for(let j=1;j<=n;j++){ const temp = dp[j]; const cost = a[i-1]===b[j-1] ? 0 : 1; dp[j] = Math.min(dp[j]+1, dp[j-1]+1, prev + cost); prev = temp; } } return dp[n]; } function isLikelyHeading(line){ if(!line) return false; const t = line.trim(); if(t.length===0 || t.length>80) return false; if(/\|/.test(t)) return false; if(/^[A-Z0-9 \-()']+$/.test(t) && t.split(' ').length<=6) return true; if(/\w+\s*\(.+\)/.test(t)) return true; if(/^([A-Za-z]{3,}\s?){1,4}$/.test(t)) return true; return false; } async function parsePdfToPages(pdfPath){ const data = fs.readFileSync(pdfPath); const parsed = await pdf(data); const raw = parsed.text || ''; const pages = raw.split('\f').map(p=>p.replace(/\r/g,'').trim()); return pages; } function extractSkillBlocksFromPages(pages){ const blocks = []; let current = null; for(let pi=0; picleanLine(l)); for(let li=0; li({ title: collapseText(b.title), content: collapseText(joinHyphenation(b.content)), page: b.page, source: b.source })); } function filterShort(blocks){ return blocks.filter(b=>{ const t = (b.title||''); const c = (b.content||''); if(t.length<2 || t.length>80) return false; if(c.replace(/\s+/g,'').length < 80) return false; // require longer content for safety return true; }); } function loadCanonicalTitles(){ const raw = JSON.parse(fs.readFileSync(rulesJson,'utf8')); const rules = raw.rules || []; const titles = new Set(); for(const r of rules){ if(!r.title) continue; const cat = (r.category||'').toLowerCase(); if(cat && cat.indexOf('skill')===-1 && cat!=='skills' && cat!=='general') continue; const n = normalizeForMatch(r.title); if(n) titles.add(n); } return Array.from(titles); } function matchToWhitelist(titleNorm, whitelist){ // Only allow exact or prefix matches to tighten imports (no fuzzy/substr) if(!titleNorm) return false; for(const canon of whitelist){ if(canon === titleNorm) return { type: 'exact', canon }; if(canon.startsWith(titleNorm) || titleNorm.startsWith(canon)) return { type: 'prefix', canon }; } return false; } async function run(){ console.log('Parsing PDF:', pdfPath); const pages = await parsePdfToPages(pdfPath); console.log('PDF parsed into', pages.length, 'pages'); const startRe = /chapter\s*iii[:\s]|chapter\s*3[:\s]|chapter\s*iii\b|chapter\s*iii\b.*skills/i; const endRe = /chapter\s*(?:iv|4)[:\s]|chapter\s*4\b|chapter\s*iv\b|chapter\s*iv\b.*talent/i; let startPage=-1, endPage=-1; for(let i=0;istartPage) ? endPage : Math.min(pages.length, startPage+30); pagesForExtraction = pages.slice(from,to); console.log('Restricted extraction to pages', from+1, '->', to); } else { console.log('Chapter III marker not found; extracting from entire document'); } const blocks = extractSkillBlocksFromPages(pagesForExtraction); console.log('Extracted', blocks.length, 'raw blocks'); const candidates = filterShort(blocks); console.log('After short-filter', candidates.length, 'candidates'); const whitelist = loadCanonicalTitles(); console.log('Loaded', whitelist.length, 'canonical titles from rules JSON'); const matched = []; for(const b of candidates){ const n = normalizeForMatch(b.title); const m = matchToWhitelist(n, whitelist); if(m){ matched.push(Object.assign({}, b, { matched: m })); } } console.log('Matched', matched.length, 'blocks against whitelist'); if(matched.length===0){ console.error('No matches found, aborting'); db.close(); process.exit(1); } const deleteInfo = db.prepare("DELETE FROM rules WHERE category = 'skills'").run(); console.log('Deleted skill rows:', deleteInfo.changes); const insertStmt = db.prepare(`INSERT INTO rules (rule_id,title,content,page,source,source_abbr,category,created_at) VALUES (?,?,?,?,?,?,?,datetime('now'))`); const slug = s => String(s||'').replace(/[^a-z0-9]+/ig,'_').replace(/^_+|_+$/g,'').toLowerCase(); const insertTx = db.transaction((rows)=>{ let i=1; for(const r of rows){ const id = `pdf_skill_whitelist_${i}_${slug(r.title.slice(0,30))}`; insertStmt.run(id, r.title, r.content, r.page||null, r.source||'Core Rulebook', 'CR', 'skills'); i++; } }); insertTx(matched); console.log('Inserted', matched.length, 'skill rows'); const report = { inserted: matched.length, sample: matched.slice(0,100) }; const reportFile = path.join(backupsDir, `rules-pdf-import-whitelist-report-${nowTs()}.json`); fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); console.log('Wrote report to', reportFile); db.close(); console.log('Done'); } run().catch(err=>{ console.error('Import failed:', err && err.stack ? err.stack : err); process.exit(1); });