244 lines
8.2 KiB
JavaScript
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
|
|
};
|