var zlib = require('zlib'); function sanitizeType(t) { return t === 'pager' ? 'pager' : 'extension'; } function clip(s, n) { return String(s == null ? '' : s).trim().substring(0, n || 200); } function normalizeExportItem(raw) { return { location: clip(raw && raw.location, 120), name: clip(raw && raw.name, 120), number: clip(raw && raw.number, 40), type: sanitizeType(raw && raw.type), notes: clip(raw && raw.notes, 500) }; } function isValidImportItem(item) { return !!(item && item.location && item.name && item.number); } function itemKey(item) { return [item.location, item.name, item.number, item.type, item.notes].map(function(v) { return String(v || '').trim().toLowerCase(); }).join('\u001f'); } function looseKeys(item) { var location = String(item && item.location || '').trim().toLowerCase(); var number = String(item && item.number || '').trim().toLowerCase(); var type = sanitizeType(item && item.type); var keys = []; if (location && number) keys.push('location-number:' + location + '\u001f' + number); if (number && type) keys.push('number-type:' + number + '\u001f' + type); return keys; } function exportPayload(rows) { return { format: 'pedscribe-phone-directory', version: 1, exported_at: new Date().toISOString(), items: (rows || []).map(normalizeExportItem).filter(isValidImportItem) }; } function importItemsFromBody(body) { var rawItems = Array.isArray(body) ? body : (body && Array.isArray(body.items) ? body.items : []); var seen = Object.create(null); return rawItems.slice(0, 1000).map(normalizeExportItem).filter(isValidImportItem).filter(function(item) { var key = itemKey(item); if (seen[key]) return false; seen[key] = true; return true; }); } function analyzeImport(items, existingRows) { var exact = Object.create(null); var loose = Object.create(null); (existingRows || []).forEach(function(row) { var normalized = normalizeExportItem(row); var existing = { id: row.id, location: normalized.location, name: normalized.name, number: normalized.number, type: normalized.type, notes: normalized.notes, trashed: row.trashed_at != null }; exact[itemKey(normalized)] = existing; looseKeys(normalized).forEach(function(key) { if (!loose[key]) loose[key] = []; loose[key].push(existing); }); }); var summary = { total: items.length, new: 0, exactActive: 0, exactTrashed: 0, possible: 0 }; var entries = items.map(function(item, index) { var exactMatch = exact[itemKey(item)]; var possibleMap = Object.create(null); looseKeys(item).forEach(function(key) { (loose[key] || []).forEach(function(match) { possibleMap[String(match.id)] = match; }); }); var possibleMatches = Object.keys(possibleMap).map(function(id) { return possibleMap[id]; }); var status = 'new'; if (exactMatch && exactMatch.trashed) status = 'exact_trashed'; else if (exactMatch) status = 'exact_active'; else if (possibleMatches.length) status = 'possible_duplicate'; if (status === 'exact_active') summary.exactActive++; else if (status === 'exact_trashed') summary.exactTrashed++; else if (status === 'possible_duplicate') summary.possible++; else summary.new++; var entry = { index: index, status: status, item: item, exactMatch: exactMatch || null, possibleMatches: possibleMatches.slice(0, 5) }; var importedCandidate = { id: 'import-' + index, location: item.location, name: item.name, number: item.number, type: item.type, notes: item.notes, trashed: false, incoming: true }; looseKeys(item).forEach(function(key) { if (!loose[key]) loose[key] = []; loose[key].push(importedCandidate); }); return entry; }); return { summary: summary, entries: entries }; } function crc32(buf) { var table = crc32.table; if (!table) { table = crc32.table = []; for (var i = 0; i < 256; i++) { var c = i; for (var j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); table[i] = c >>> 0; } } var crc = 0xffffffff; for (var k = 0; k < buf.length; k++) crc = table[(crc ^ buf[k]) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function dosDateTime(date) { var d = date || new Date(); var time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2); var day = Math.max(1, d.getDate()); var month = d.getMonth() + 1; var year = Math.max(1980, d.getFullYear()) - 1980; return { time: time, date: (year << 9) | (month << 5) | day }; } function createJsonZip(filename, payload) { var name = Buffer.from(filename || 'pedscribe-extensions.json'); var data = Buffer.from(JSON.stringify(payload, null, 2)); var stamp = dosDateTime(new Date()); var crc = crc32(data); var local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt16LE(stamp.time, 10); local.writeUInt16LE(stamp.date, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); var central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(stamp.time, 12); central.writeUInt16LE(stamp.date, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE(0, 38); central.writeUInt32LE(0, 42); var centralOffset = local.length + name.length + data.length; var centralSize = central.length + name.length; var end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10); end.writeUInt32LE(centralSize, 12); end.writeUInt32LE(centralOffset, 16); end.writeUInt16LE(0, 20); return Buffer.concat([local, name, data, central, name, end]); } function parseImportBuffer(buffer) { var buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || ''); var maxPayload = 4 * 1024 * 1024; if (buf.length > maxPayload) throw new Error('Import payload exceeds 4 MiB'); if (buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50) { if (buf.length < 30) throw new Error('Invalid zip file'); // Only stored/deflated, unencrypted entries with sizes in the local header. var method = buf.readUInt16LE(8); var allowedFlags = 0x800 | (method === 8 ? 0x0006 : 0); if (buf.readUInt16LE(4) > 20 || (buf.readUInt16LE(6) & ~allowedFlags)) throw new Error('Unsupported zip flags/version'); if (method !== 0 && method !== 8) throw new Error('Unsupported zip compression'); var plainSize = buf.readUInt32LE(22); if (plainSize > maxPayload) throw new Error('Import payload exceeds 4 MiB'); var compressedSize = buf.readUInt32LE(18); var nameLength = buf.readUInt16LE(26); var extraLength = buf.readUInt16LE(28); var dataStart = 30 + nameLength + extraLength; var dataEnd = dataStart + compressedSize; if (!nameLength || dataStart > buf.length || dataEnd > buf.length) throw new Error('Invalid zip file'); var data = buf.slice(dataStart, dataEnd); if (method === 8) { var inflated = zlib.inflateRawSync(data, { maxOutputLength: maxPayload, info: true }); if (inflated.engine.bytesWritten !== compressedSize) throw new Error('Invalid zip compressed size'); data = inflated.buffer; } if (data.length > maxPayload || data.length !== plainSize || crc32(data) !== buf.readUInt32LE(14)) { throw new Error('Invalid zip payload'); } return JSON.parse(data.toString('utf8')); } return JSON.parse(buf.toString('utf8')); } module.exports = { sanitizeType, clip, normalizeExportItem, isValidImportItem, itemKey, looseKeys, exportPayload, importItemsFromBody, analyzeImport, createJsonZip, parseImportBuffer };