c7ce87afd5
- Hardcode GnuMICR.otf path in pdfService.js; remove MICR_FONT_PATH env var - Fix normalizeDate to handle MM/DD/YY (2-digit year) and return null on no match - Fix generatePdf button DOM bug: update span directly instead of overwriting textContent - Remove .env.example and NTFY_URL from docker-compose (app has no required config) - Remove redundant fonts volume mount from docker-compose (fonts bundled in image) - Mark MVP TODO items complete; add // TODO comments in source for post-MVP features - Update README: correct slot height, remove stale env var docs
380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* import-mdb.js
|
|
*
|
|
* One-time migration: reads a single ezCheckPrinting .mdb file and imports
|
|
* account config, check layout, and check records into the SQLite database.
|
|
*
|
|
* Prerequisites:
|
|
* - mdbtools installed: `sudo apt install mdbtools` or brew install mdbtools
|
|
* - SQLite DB initialized (runs automatically on first require of database.js)
|
|
*
|
|
* Usage:
|
|
* node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb"
|
|
* node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb" --dry-run
|
|
*/
|
|
|
|
const { execSync } = require('child_process');
|
|
const path = require('path');
|
|
const db = require('../src/db/database');
|
|
|
|
// ---- CLI args ---------------------------------------------------------------
|
|
|
|
const args = process.argv.slice(2);
|
|
const fileIndex = args.indexOf('--file');
|
|
if (fileIndex === -1 || !args[fileIndex + 1]) {
|
|
console.error('Usage: node migrations/import-mdb.js --file "/path/to/Account.mdb"');
|
|
process.exit(1);
|
|
}
|
|
|
|
const mdbFile = args[fileIndex + 1];
|
|
const dryRun = args.includes('--dry-run');
|
|
|
|
if (dryRun) {
|
|
console.log('[dry-run] No data will be written to the database.');
|
|
}
|
|
|
|
// ---- mdbtools helpers -------------------------------------------------------
|
|
|
|
function mdbExport(table) {
|
|
try {
|
|
const output = execSync(`mdb-export "${mdbFile}" ${table}`, {
|
|
encoding: 'utf8',
|
|
maxBuffer: 50 * 1024 * 1024,
|
|
});
|
|
return parseCsv(output);
|
|
} catch (err) {
|
|
console.error(`Failed to export table ${table}:`, err.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Minimal CSV parser. Handles quoted fields with embedded commas and newlines.
|
|
* Not a full RFC 4180 implementation but sufficient for mdb-export output.
|
|
*/
|
|
function parseCsv(text) {
|
|
const lines = text.trim().split('\n');
|
|
if (lines.length < 2) return [];
|
|
|
|
const headers = splitCsvLine(lines[0]);
|
|
const rows = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const values = splitCsvLine(lines[i]);
|
|
if (values.length === 0) continue;
|
|
const row = {};
|
|
headers.forEach((h, idx) => {
|
|
row[h.trim()] = values[idx] !== undefined ? values[idx] : null;
|
|
});
|
|
rows.push(row);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function splitCsvLine(line) {
|
|
const result = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (ch === '"') {
|
|
if (inQuotes && line[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (ch === ',' && !inQuotes) {
|
|
result.push(current);
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
result.push(current);
|
|
return result;
|
|
}
|
|
|
|
// ---- Font name normalization -------------------------------------------------
|
|
// .mdb stores Windows font names; map common ones to PDFKit built-ins.
|
|
// Any unmapped font will fall back to Helvetica at render time.
|
|
const FONT_MAP = {
|
|
'Times New Roman': 'Times-Roman',
|
|
'Helsinki': 'Helvetica',
|
|
'Arial': 'Helvetica',
|
|
'Courier New': 'Courier',
|
|
};
|
|
|
|
function normalizeFont(fontName, isBold) {
|
|
const mapped = FONT_MAP[fontName] || 'Helvetica';
|
|
if (isBold) {
|
|
if (mapped === 'Times-Roman') return 'Times-Bold';
|
|
if (mapped === 'Helvetica') return 'Helvetica-Bold';
|
|
if (mapped === 'Courier') return 'Courier-Bold';
|
|
}
|
|
return mapped;
|
|
}
|
|
|
|
// TODO: Support multi-account .mdb import -- run migration per account and associate records with account_id
|
|
|
|
// ---- Import: T100 (account config) ------------------------------------------
|
|
|
|
function importAccount() {
|
|
console.log('\n--- Importing account config (T100) ---');
|
|
const rows = mdbExport('T100');
|
|
|
|
if (rows.length === 0) {
|
|
console.error('No rows in T100. Is this a valid ezCheckPrinting .mdb?');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Take the first (and typically only) row
|
|
const r = rows[0];
|
|
console.log(`Account: ${r.Company1} / Bank: ${r.BankName}`);
|
|
console.log(`Routing: ${r.BankRouteNo} | Account: ${r.BankAccountNo}`);
|
|
console.log(`Current check no: ${r.CurrentCheckNo}`);
|
|
|
|
// Logo is stored as base64 in the Settings table (not T100)
|
|
// We fetch it separately below and update after insert.
|
|
const accountData = {
|
|
bank_name: r.BankName?.trim() || '',
|
|
bank_info1: r.BankInfo1?.trim() || null,
|
|
bank_info2: r.BankInfo2?.trim() || null,
|
|
bank_info3: r.BankInfo3?.trim() || null,
|
|
transit_code: r.TransitCode?.trim() || null,
|
|
routing_number: r.BankRouteNo?.trim() || '',
|
|
account_number: r.BankAccountNo?.trim() || '',
|
|
start_check_no: parseInt(r.StartCheckNo) || 1000,
|
|
current_check_no: parseInt(r.CurrentCheckNo) || 1000,
|
|
check_width: parseFloat(r.CheckWidth) || 8.5,
|
|
check_height: parseFloat(r.CheckHeight) || 3.5,
|
|
offset_left: parseFloat(r.OffsetLeft) || 0,
|
|
offset_right: parseFloat(r.OffsetRight) || 0,
|
|
offset_up: parseFloat(r.OffsetUp) || 0,
|
|
offset_down: parseFloat(r.OffsetDown) || 0,
|
|
company1: r.Company1?.trim() || null,
|
|
company2: r.Company2?.trim() || null,
|
|
company3: r.Company3?.trim() || null,
|
|
company4: r.Company4?.trim() || null,
|
|
blank_stock: r.BlankBankStock === 'true' || r.BlankBankStock === '1' ? 1 : 0,
|
|
check_position: r.ExField1?.trim() || '3-per-page',
|
|
};
|
|
|
|
if (!dryRun) {
|
|
// Delete existing account row (single-account Phase 1 assumption)
|
|
db.prepare('DELETE FROM account').run();
|
|
db.prepare(`
|
|
INSERT INTO account (
|
|
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
|
routing_number, account_number, start_check_no, current_check_no,
|
|
check_width, check_height, offset_left, offset_right, offset_up, offset_down,
|
|
company1, company2, company3, company4,
|
|
blank_stock, check_position
|
|
) VALUES (
|
|
@bank_name, @bank_info1, @bank_info2, @bank_info3, @transit_code,
|
|
@routing_number, @account_number, @start_check_no, @current_check_no,
|
|
@check_width, @check_height, @offset_left, @offset_right, @offset_up, @offset_down,
|
|
@company1, @company2, @company3, @company4,
|
|
@blank_stock, @check_position
|
|
)
|
|
`).run(accountData);
|
|
console.log('Account config imported.');
|
|
} else {
|
|
console.log('[dry-run] Would insert:', JSON.stringify(accountData, null, 2));
|
|
}
|
|
|
|
return accountData;
|
|
}
|
|
|
|
// ---- Import: Settings (logo image) ------------------------------------------
|
|
|
|
function importLogo() {
|
|
console.log('\n--- Importing logo from Settings table ---');
|
|
const rows = mdbExport('Settings');
|
|
const logoRow = rows.find(r => r.SettingKey === 'LogoImg');
|
|
|
|
if (!logoRow || !logoRow.SettingValue) {
|
|
console.log('No logo found in Settings table.');
|
|
return;
|
|
}
|
|
|
|
// Value is raw base64 (GIF format based on the data we saw)
|
|
const base64Data = logoRow.SettingValue.trim();
|
|
const dataUri = `data:image/gif;base64,${base64Data}`;
|
|
|
|
if (!dryRun) {
|
|
db.prepare('UPDATE account SET logo_data = ? WHERE id = 1').run(dataUri);
|
|
console.log(`Logo imported (${Math.round(base64Data.length / 1024)} KB base64).`);
|
|
} else {
|
|
console.log(`[dry-run] Would import logo (${Math.round(base64Data.length / 1024)} KB base64).`);
|
|
}
|
|
}
|
|
|
|
// ---- Import: T200 (check layout fields) -------------------------------------
|
|
|
|
function importLayoutFields() {
|
|
console.log('\n--- Importing check layout fields (T200) ---');
|
|
const rows = mdbExport('T200');
|
|
console.log(`Found ${rows.length} layout fields.`);
|
|
|
|
if (!dryRun) {
|
|
db.prepare('DELETE FROM layout_fields').run();
|
|
}
|
|
|
|
const insert = db.prepare(`
|
|
INSERT INTO layout_fields (
|
|
field_name, field_text, font_name, font_size, font_bold,
|
|
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos,
|
|
visible, not_for_preprint
|
|
) VALUES (
|
|
@field_name, @field_text, @font_name, @font_size, @font_bold,
|
|
@field_type, @line_thick, @x_pos, @y_pos, @x_end_pos, @y_end_pos,
|
|
@visible, @not_for_preprint
|
|
)
|
|
`);
|
|
|
|
let count = 0;
|
|
for (const r of rows) {
|
|
const isBold = r.FldFontType === '1';
|
|
const fieldData = {
|
|
field_name: r.FldName?.trim() || '',
|
|
field_text: r.FldText?.trim() || null,
|
|
font_name: normalizeFont(r.FldFontName?.trim(), isBold),
|
|
font_size: parseFloat(r.FldFontSize) || 10,
|
|
font_bold: isBold ? 1 : 0,
|
|
field_type: r.FldType?.trim() || 'Regular',
|
|
line_thick: parseInt(r.LnThick) || 1,
|
|
x_pos: parseFloat(r.XPos) || 0,
|
|
y_pos: parseFloat(r.YPos) || 0,
|
|
x_end_pos: parseFloat(r.XEndPos) || 0,
|
|
y_end_pos: parseFloat(r.YEndPos) || 0,
|
|
visible: r.Display === '1' ? 1 : 0,
|
|
not_for_preprint: parseInt(r.NotForPreprint) || 0,
|
|
};
|
|
|
|
if (!dryRun) {
|
|
insert.run(fieldData);
|
|
} else {
|
|
console.log(` [dry-run] ${fieldData.field_name}: type=${fieldData.field_type} x=${fieldData.x_pos} y=${fieldData.y_pos}`);
|
|
}
|
|
count++;
|
|
}
|
|
|
|
console.log(`${dryRun ? '[dry-run] Would import' : 'Imported'} ${count} layout fields.`);
|
|
}
|
|
|
|
// ---- Import: T104 (check records) -------------------------------------------
|
|
|
|
function importChecks() {
|
|
console.log('\n--- Importing check records (T104) ---');
|
|
const rows = mdbExport('T104');
|
|
console.log(`Found ${rows.length} check records.`);
|
|
|
|
if (rows.length === 0) {
|
|
console.log('No checks to import.');
|
|
return;
|
|
}
|
|
|
|
if (!dryRun) {
|
|
db.prepare('DELETE FROM checks').run();
|
|
}
|
|
|
|
const insert = db.prepare(`
|
|
INSERT INTO checks (
|
|
check_no, payee, amount, check_date, memo, note1, note2,
|
|
payee_address1, payee_address2, payee_address3, payee_address4,
|
|
printed, add_date, mdb_check_id
|
|
) VALUES (
|
|
@check_no, @payee, @amount, @check_date, @memo, @note1, @note2,
|
|
@payee_address1, @payee_address2, @payee_address3, @payee_address4,
|
|
@printed, @add_date, @mdb_check_id
|
|
)
|
|
`);
|
|
|
|
let count = 0;
|
|
let skipped = 0;
|
|
for (const r of rows) {
|
|
// Normalize date: .mdb uses MM/DD/YYYY or similar; convert to YYYY-MM-DD
|
|
const rawDate = r.CheckDate?.trim() || '';
|
|
const checkDate = normalizeDate(rawDate);
|
|
|
|
const addDate = normalizeDate(r.AddDate?.trim() || '') || new Date().toISOString();
|
|
|
|
const checkData = {
|
|
check_no: parseInt(r.CheckNo),
|
|
payee: r.Payee?.trim() || '',
|
|
amount: parseFloat(r.Amount) || 0,
|
|
check_date: checkDate,
|
|
memo: r.Memo?.trim() || null,
|
|
note1: r.Note1?.trim() || null,
|
|
note2: r.Note2?.trim() || null,
|
|
payee_address1: r.PayeeAddress1?.trim() || null,
|
|
payee_address2: r.PayeeAddress2?.trim() || null,
|
|
payee_address3: r.PayeeAddress3?.trim() || null,
|
|
payee_address4: r.PayeeAddress4?.trim() || null,
|
|
printed: r.checked === 'true' || r.checked === '1' ? 1 : 0,
|
|
add_date: addDate,
|
|
mdb_check_id: parseInt(r.CheckID) || null,
|
|
};
|
|
|
|
if (!checkData.check_no || !checkData.payee) {
|
|
console.warn(` Skipping record with missing check_no or payee:`, r);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
if (!dryRun) {
|
|
try {
|
|
insert.run(checkData);
|
|
} catch (err) {
|
|
console.warn(` Skipping duplicate check #${checkData.check_no}:`, err.message);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
} else {
|
|
console.log(` [dry-run] Check #${checkData.check_no}: ${checkData.payee} $${checkData.amount} ${checkData.check_date}`);
|
|
}
|
|
count++;
|
|
}
|
|
|
|
console.log(`${dryRun ? '[dry-run] Would import' : 'Imported'} ${count} checks.${skipped > 0 ? ` Skipped ${skipped}.` : ''}`);
|
|
}
|
|
|
|
// ---- Date normalization -----------------------------------------------------
|
|
|
|
function normalizeDate(raw) {
|
|
if (!raw) return null;
|
|
// mdb-export outputs dates as "MM/DD/YYYY HH:MM:SS", "MM/DD/YY", or "YYYY-MM-DD"
|
|
const mdyMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
|
|
if (mdyMatch) {
|
|
const [, m, d, y] = mdyMatch;
|
|
const year = y.length === 2
|
|
? (parseInt(y, 10) >= 50 ? '19' : '20') + y
|
|
: y;
|
|
return `${year}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
|
}
|
|
const isoMatch = raw.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
if (isoMatch) return isoMatch[1];
|
|
return null;
|
|
}
|
|
|
|
// ---- Run --------------------------------------------------------------------
|
|
|
|
console.log(`\nImporting from: ${mdbFile}`);
|
|
console.log(`Target database: ${process.env.DB_PATH || 'data/ezcheck.db'}`);
|
|
|
|
try {
|
|
importAccount();
|
|
importLogo();
|
|
importLayoutFields();
|
|
importChecks();
|
|
console.log('\nMigration complete.');
|
|
} catch (err) {
|
|
console.error('\nMigration failed:', err);
|
|
process.exit(1);
|
|
}
|