369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
(function exposeDeviceTree(globalScope) {
|
|
const { toWellFormedString } = typeof module !== 'undefined' && module.exports
|
|
? require('./well-formed-string')
|
|
: globalScope.AptWellFormedString;
|
|
const SPECIAL = Object.freeze({
|
|
ORPHAN_SITE: '__orphaned__',
|
|
UNGROUPED: '__ungrouped__',
|
|
UNKNOWN_GROUP_PREFIX: '__unknown_group__:',
|
|
UNKNOWN_SITE_PREFIX: '__unknown_site__:',
|
|
});
|
|
|
|
function text(value) {
|
|
return value === null || value === undefined ? '' : toWellFormedString(value).trim();
|
|
}
|
|
|
|
function normalized(value) {
|
|
return text(value).normalize('NFKD').toLocaleLowerCase();
|
|
}
|
|
|
|
function keyPart(value) {
|
|
const encoded = encodeURIComponent(toWellFormedString(value));
|
|
return `${encoded.length}:${encoded}`;
|
|
}
|
|
|
|
function compareNodes(left, right) {
|
|
const nameOrder = left.normalizedName === right.normalizedName
|
|
? 0
|
|
: (left.normalizedName < right.normalizedName ? -1 : 1);
|
|
const idOrder = left.canonicalId === right.canonicalId
|
|
? 0
|
|
: (left.canonicalId < right.canonicalId ? -1 : 1);
|
|
return nameOrder || idOrder || left.ordinal - right.ordinal;
|
|
}
|
|
|
|
function createIdentity(kind, item, ordinal, occurrences, diagnostics) {
|
|
const rawId = text(item && item.id);
|
|
const canonicalId = rawId || `__invalid_${kind}_${ordinal}`;
|
|
const count = (occurrences.get(canonicalId) || 0) + 1;
|
|
occurrences.set(canonicalId, count);
|
|
if (!rawId) diagnostics.push({ code: 'malformed-id', kind, ordinal, id: rawId });
|
|
if (count > 1) diagnostics.push({ code: 'duplicate-id', kind, ordinal, id: canonicalId });
|
|
return {
|
|
rawId,
|
|
canonicalId,
|
|
uniqueId: count === 1 ? canonicalId : `${canonicalId}~${count}`,
|
|
};
|
|
}
|
|
|
|
function makeSite(id, name, ordinal, extra) {
|
|
const syntheticType = extra && extra.syntheticType;
|
|
return Object.assign({
|
|
kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
|
key: syntheticType
|
|
? `site:s:${keyPart(syntheticType)}:${keyPart(id)}`
|
|
: `site:t:${keyPart(id)}`,
|
|
pendingDeletion: false, groups: [], synthetic: false,
|
|
}, extra);
|
|
}
|
|
|
|
function makeGroup(site, id, name, ordinal, extra) {
|
|
const syntheticType = extra && extra.syntheticType;
|
|
return Object.assign({
|
|
kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
|
key: syntheticType
|
|
? `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`
|
|
: `group:t:${keyPart(site.key)}:${keyPart(id)}`,
|
|
parentKey: site.key, site, cameras: [],
|
|
pendingDeletion: false, synthetic: false,
|
|
}, extra);
|
|
}
|
|
|
|
function buildDeviceTree(payload = {}) {
|
|
const diagnostics = [];
|
|
const sourceSites = Array.isArray(payload.sites) ? payload.sites : [];
|
|
const sourceGroups = Array.isArray(payload.groups) ? payload.groups : [];
|
|
const sourceDevices = Array.isArray(payload.devices) ? payload.devices : [];
|
|
const rowByKey = new Map();
|
|
const cameraByKey = new Map();
|
|
const sites = [];
|
|
const siteByRawId = new Map();
|
|
const groupByRawId = new Map();
|
|
const groupOccurrences = new Map();
|
|
const siteOccurrences = new Map();
|
|
const cameraOccurrences = new Map();
|
|
const syntheticSites = new Map();
|
|
let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length;
|
|
|
|
function registerRow(node) {
|
|
if (!rowByKey.has(node.key)) {
|
|
rowByKey.set(node.key, node);
|
|
return true;
|
|
}
|
|
const collidedKey = node.key;
|
|
let collisionOrdinal = 2;
|
|
do {
|
|
node.key = `${collidedKey}:collision:${collisionOrdinal}`;
|
|
collisionOrdinal += 1;
|
|
} while (rowByKey.has(node.key));
|
|
diagnostics.push({
|
|
code: 'row-key-collision', key: collidedKey, resolvedKey: node.key,
|
|
kind: node.kind, id: node.canonicalId,
|
|
});
|
|
rowByKey.set(node.key, node);
|
|
return false;
|
|
}
|
|
|
|
sourceSites.forEach((item, ordinal) => {
|
|
const safe = item && typeof item === 'object' ? item : {};
|
|
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
|
|
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
|
canonicalId: identity.canonicalId,
|
|
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
|
source: {
|
|
id: identity.canonicalId,
|
|
name: text(safe.name),
|
|
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
|
},
|
|
});
|
|
sites.push(site);
|
|
registerRow(site);
|
|
if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site);
|
|
});
|
|
|
|
function ensureUnknownSite(rawSiteId) {
|
|
const labelId = text(rawSiteId);
|
|
const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE;
|
|
const syntheticType = labelId ? 'unknown-site' : 'orphan';
|
|
const identity = `${syntheticType}:${keyPart(labelId)}`;
|
|
let site = syntheticSites.get(identity);
|
|
if (!site) {
|
|
const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras';
|
|
site = makeSite(id, name, syntheticOrdinal++, {
|
|
synthetic: true, syntheticType, hierarchyStatus: syntheticType,
|
|
});
|
|
sites.push(site);
|
|
registerRow(site);
|
|
syntheticSites.set(identity, site);
|
|
}
|
|
return site;
|
|
}
|
|
|
|
sourceGroups.forEach((item, ordinal) => {
|
|
const safe = item && typeof item === 'object' ? item : {};
|
|
const identity = createIdentity('group', safe, ordinal, groupOccurrences, diagnostics);
|
|
const parentId = text(safe.parentId);
|
|
const site = siteByRawId.get(parentId) || ensureUnknownSite(parentId);
|
|
if (!siteByRawId.has(parentId)) diagnostics.push({ code: parentId ? 'unknown-parent-site' : 'malformed-parent-site', key: `group:${site.id}:${identity.uniqueId}`, id: parentId });
|
|
const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
|
canonicalId: identity.canonicalId,
|
|
rawParentId: parentId,
|
|
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
|
source: {
|
|
id: identity.canonicalId,
|
|
name: text(safe.name),
|
|
parentId,
|
|
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
|
},
|
|
});
|
|
site.groups.push(group);
|
|
registerRow(group);
|
|
if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group);
|
|
});
|
|
|
|
function ensureGroup(site, id, name, status, template) {
|
|
const syntheticType = status === 'conflict' ? 'conflict' : status;
|
|
const key = `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`;
|
|
let group = rowByKey.get(key);
|
|
if (!group) {
|
|
group = makeGroup(site, id, name, syntheticOrdinal++, {
|
|
synthetic: true, syntheticType,
|
|
hierarchyStatus: status,
|
|
canonicalId: template ? template.canonicalId : id,
|
|
pendingDeletion: template ? template.pendingDeletion : false,
|
|
source: template ? template.source : undefined,
|
|
});
|
|
site.groups.push(group);
|
|
registerRow(group);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
sourceDevices.forEach((item, ordinal) => {
|
|
const safe = item && typeof item === 'object' ? item : {};
|
|
const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics);
|
|
const cameraKey = `camera:t:${keyPart(identity.uniqueId)}`;
|
|
const directSiteId = text(safe.siteId);
|
|
const requestedGroupId = text(safe.deviceGroupId);
|
|
const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined;
|
|
let site;
|
|
let hierarchyStatus = text(safe.hierarchyStatus) || 'assigned';
|
|
|
|
if (directSiteId) {
|
|
site = siteByRawId.get(directSiteId) || ensureUnknownSite(directSiteId);
|
|
if (!siteByRawId.has(directSiteId)) hierarchyStatus = 'unknown-site';
|
|
} else if (knownGroup) {
|
|
site = knownGroup.site;
|
|
hierarchyStatus = 'inferred-site';
|
|
} else {
|
|
site = ensureUnknownSite('');
|
|
hierarchyStatus = requestedGroupId ? 'unknown-group' : 'orphan';
|
|
}
|
|
|
|
let group;
|
|
if (!requestedGroupId) {
|
|
group = ensureGroup(site, SPECIAL.UNGROUPED, 'Ungrouped', 'ungrouped');
|
|
} else if (!knownGroup) {
|
|
group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group');
|
|
if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group';
|
|
diagnostics.push({ code: 'unknown-group', key: cameraKey, id: requestedGroupId });
|
|
} else if (knownGroup.site !== site) {
|
|
group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup);
|
|
hierarchyStatus = 'conflict';
|
|
diagnostics.push({ code: 'site-group-conflict', key: cameraKey, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
|
} else {
|
|
group = knownGroup;
|
|
}
|
|
|
|
const name = text(safe.name) || identity.canonicalId;
|
|
const source = {
|
|
id: identity.canonicalId,
|
|
name,
|
|
type: text(safe.type) || null,
|
|
model: text(safe.model) || null,
|
|
address: text(safe.address) || null,
|
|
siteId: directSiteId || null,
|
|
deviceGroupId: requestedGroupId || null,
|
|
displayStatus: text(safe.displayStatus) || null,
|
|
localStorage: typeof safe.localStorage === 'boolean' ? safe.localStorage : null,
|
|
};
|
|
const camera = {
|
|
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
|
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
|
parentKey: group.key, site, group, hierarchyStatus, source,
|
|
};
|
|
camera.searchCorpus = normalized([
|
|
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
|
].map(text).join(' '));
|
|
group.cameras.push(camera);
|
|
registerRow(camera);
|
|
cameraByKey.set(camera.key, camera);
|
|
});
|
|
|
|
sites.forEach((site) => {
|
|
site.groups.sort(compareNodes);
|
|
site.groups.forEach((group) => group.cameras.sort(compareNodes));
|
|
});
|
|
sites.sort(compareNodes);
|
|
|
|
return Object.freeze({ sites, rowByKey, cameraByKey, diagnostics, cameraCount: cameraByKey.size });
|
|
}
|
|
|
|
function rowDescriptor(node, level, expanded, selectedKey, position, size) {
|
|
const aria = {
|
|
level,
|
|
expanded: node.kind === 'camera' ? undefined : expanded,
|
|
selected: node.key === selectedKey,
|
|
posinset: position,
|
|
setsize: size,
|
|
};
|
|
return {
|
|
key: node.key, parentKey: node.parentKey || null, kind: node.kind, id: node.id,
|
|
name: node.name, pendingDeletion: node.pendingDeletion === true,
|
|
hierarchyStatus: node.hierarchyStatus, aria,
|
|
ariaLevel: aria.level, ariaExpanded: aria.expanded, ariaSelected: aria.selected,
|
|
ariaPosInSet: aria.posinset, ariaSetSize: aria.setsize,
|
|
};
|
|
}
|
|
|
|
function flattenVisibleRows(model, options = {}) {
|
|
const expandedKeys = options.expandedKeys instanceof Set ? options.expandedKeys : new Set();
|
|
const selectedKey = typeof options.selectedKey === 'string' ? options.selectedKey : null;
|
|
const matches = options.matchCameraKeys instanceof Set ? options.matchCameraKeys : null;
|
|
const rows = [];
|
|
const includedGroups = (site) => site.groups.filter((group) => !matches || group.cameras.some((camera) => matches.has(camera.key)));
|
|
const includedSites = model.sites.filter((site) => !matches || includedGroups(site).length > 0);
|
|
|
|
includedSites.forEach((site, siteIndex) => {
|
|
const siteExpanded = expandedKeys.has(site.key);
|
|
rows.push(rowDescriptor(site, 1, siteExpanded, selectedKey, siteIndex + 1, includedSites.length));
|
|
if (!siteExpanded) return;
|
|
const groups = includedGroups(site);
|
|
groups.forEach((group, groupIndex) => {
|
|
const groupExpanded = expandedKeys.has(group.key);
|
|
rows.push(rowDescriptor(group, 2, groupExpanded, selectedKey, groupIndex + 1, groups.length));
|
|
if (!groupExpanded) return;
|
|
const cameras = matches ? group.cameras.filter((camera) => matches.has(camera.key)) : group.cameras;
|
|
cameras.forEach((camera, cameraIndex) => {
|
|
rows.push(rowDescriptor(camera, 3, undefined, selectedKey, cameraIndex + 1, cameras.length));
|
|
});
|
|
});
|
|
});
|
|
Object.defineProperty(rows, 'hiddenSelected', {
|
|
value: Boolean(selectedKey && model.rowByKey.has(selectedKey) && !rows.some((row) => row.key === selectedKey)),
|
|
enumerable: true,
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function calculateVirtualWindow(rows, options = {}) {
|
|
const rowHeight = Number.isFinite(options.rowHeight) && options.rowHeight > 0 ? options.rowHeight : 28;
|
|
const viewportHeight = Number.isFinite(options.viewportHeight) && options.viewportHeight >= 0 ? options.viewportHeight : 0;
|
|
const requestedScrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
|
const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16);
|
|
const scrollTop = Math.min(requestedScrollTop, Math.max(0, (rows.length * rowHeight) - viewportHeight));
|
|
const visibleStart = Math.floor(scrollTop / rowHeight);
|
|
const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
|
const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan));
|
|
const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan));
|
|
return {
|
|
rows: rows.slice(startIndex, endIndex), startIndex, endIndex,
|
|
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, scrollTop,
|
|
};
|
|
}
|
|
|
|
function createSearchRunner(options = {}) {
|
|
const scheduler = typeof options.scheduler === 'function'
|
|
? options.scheduler
|
|
: (work) => setTimeout(work, 0);
|
|
const chunkSize = Number.isInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 250;
|
|
let generation = 0;
|
|
|
|
function search(model, query, searchOptions = {}) {
|
|
const myGeneration = ++generation;
|
|
const terms = normalized(query).split(/\s+/).filter(Boolean);
|
|
const cameras = [...model.cameraByKey.values()];
|
|
const cameraKeys = new Set();
|
|
const expandedKeys = new Set(searchOptions.expandedKeys instanceof Set ? searchOptions.expandedKeys : []);
|
|
let index = 0;
|
|
|
|
return new Promise((resolve) => {
|
|
function finish(cancelled) {
|
|
resolve(Object.freeze({ generation: myGeneration, cancelled, count: cameraKeys.size, cameraKeys, expandedKeys }));
|
|
}
|
|
function work() {
|
|
if (myGeneration !== generation) return finish(true);
|
|
const end = Math.min(cameras.length, index + chunkSize);
|
|
for (; index < end; index += 1) {
|
|
const camera = cameras[index];
|
|
if (terms.every((term) => camera.searchCorpus.includes(term))) {
|
|
cameraKeys.add(camera.key);
|
|
expandedKeys.add(camera.site.key);
|
|
expandedKeys.add(camera.group.key);
|
|
}
|
|
}
|
|
if (index < cameras.length) scheduler(work);
|
|
else finish(false);
|
|
}
|
|
scheduler(work);
|
|
});
|
|
}
|
|
|
|
return Object.freeze({ search, cancel() { generation += 1; }, get generation() { return generation; } });
|
|
}
|
|
|
|
const api = Object.freeze({
|
|
SPECIAL,
|
|
buildDeviceTree,
|
|
buildDeviceTreeModel: buildDeviceTree,
|
|
flattenVisibleRows,
|
|
flattenRows: flattenVisibleRows,
|
|
createSearchRunner,
|
|
calculateVirtualWindow,
|
|
});
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
if (globalScope) globalScope.AptDeviceTree = api;
|
|
}(typeof window !== 'undefined' ? window : undefined));
|