pediatric-ai-scribe-v3/src/utils/extensionTransfer.js

244 lines
8.2 KiB
JavaScript

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
};