/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
// Commmon JS library code for the whole application
// Make luxon library class more easily accessible
const DateTime = luxon.DateTime;
const Interval = luxon.Interval;
const Duration = luxon.Duration;
// Tabler 1.2.0 moved/hid the bootstrap variable and the prior tabler variable into a new global... see https://github.com/tabler/tabler/issues/2273#issuecomment-2816833153
// It broke quite a few things, so here's a quick shim...
const bootstrap = tabler.bootstrap;
var tabler = tabler.tabler;
// TODO: application vars can go in here instead of the global scope
const tlz = {
openRepos: load('open_repos') || [],
itemClassIconPaths: {
email: `
`,
message: `
`,
social: `
`,
location: `
`,
collection: `
`,
event: `
`,
"": `
`,
// because the "media" classification is so broad, we can show a more specific
// icon based on the data_type column (mime type) of the item
media_image: ``,
media_video: ``,
media_audio: ``,
// generic fallback if we don't recognize the specific type of media... a camera is the best I can think of
// since cameras can do pictures, videos, and audio
media: ``,
bookmark: `
`,
page_view: `
`,
document: `
`,
},
itemClassIconAndLabel(item, pathOnly) {
const info = classInfo(item?.classification);
const iconMap = pathOnly ? tlz.itemClassIconPaths : tlz.itemClassIcons;
if (item?.classification == "media") {
if (item?.data_type?.startsWith("image/")) {
return {
icon: iconMap.media_image,
label: info.labels[0] + " (image)"
};
} else if (item?.data_type?.startsWith("video/")) {
return {
icon: iconMap.media_video,
label: info.labels[0] + " (video)"
};
} else if (item?.data_type?.startsWith("audio/")) {
return {
icon: iconMap.media_audio,
label: info.labels[0] + " (audio)"
};
}
}
return {
icon: iconMap[item?.classification || ""],
label: info.labels[0]
};
},
attributeLabels: {
"_entity": "Entity ID",
"email_address": "Email address",
"phone_number": "Phone number",
"facebook_username": "Facebook",
"facebook_name": "Name on Facebook",
"google_photos_name": "Name on Google Photos",
"instagram_username": "Instagram",
"instagram_name": "Name on Instagram",
"instagram_bio": "Instagram bio",
"strava_athlete_id": "Strava",
"telegram_id": "Telegram",
"twitter_username": "Twitter",
"google_location_device": "Google Location Device",
"url": "Website",
"twitter_id": "Twitter ID",
"twitter_location": "Location in Twitter bio",
},
colorClasses: [
"blue",
"azure",
"indigo",
"purple",
"pink",
"red",
"orange",
"yellow",
"lime",
"green",
"teal",
"cyan",
"blue-lt",
"azure-lt",
"indigo-lt",
"purple-lt",
"pink-lt",
"red-lt",
"orange-lt",
"yellow-lt",
"lime-lt",
"green-lt",
"teal-lt",
"cyan-lt"
],
// map of filepicker names to last settings/state (like path)
filePickers: {},
// counter for IDs of collapsable regions which may be dynamically created
collapseCounter: 0,
// keeps statistics for active jobs, keyed by job ID
jobStats: {},
// this will hold the connection to the server's real-time logger WebSocket and related state
loggerSocket: {},
// These intervals are cleared when the page freezes, and restarted when the page unfreezes.
// The values in this object are objects with this structure:
// { set(), interval }
// where set() returns the result of setInterval(), and interval is the
// returned interval that can be cleared.
intervals: {
// Update the dynamic timestamps (and durations) every second to keep them accurate
dynamicTime: {
set() {
return setInterval(function() {
for (const elem of $$('.dynamic-time')) {
elem.innerText = elem._timestamp.toRelative();
}
for (const elem of $$('.dynamic-duration')) {
// don't use diffNow() because it's implemented backwards (durations are always negative)!
elem.innerText = betterToHuman(DateTime.now().diff(elem._timestamp));
}
}, 1000);
}
}
},
// these values are used for moving the map to containers
// closest to the cursor; the set makes the distance search
// more efficient by limiting it to those in the viewport
mapsInViewport: new Set(),
nearestMapElem: null
};
// Icons associated with each class of item.
tlz.itemClassIcons = {
email: `
`,
message: `
`,
social: `
`,
location: `
`,
collection: `
`,
event: `
`,
"": `
`,
// because the "media" classification is so broad, we can show a more specific
// icon based on the data_type column (mime type) of the item
media_image: `
`,
media_video: `
`,
media_audio: `
`,
// generic fallback if we don't recognize the specific type of media... a camera is the best I can think of
// since cameras can do pictures, videos, and audio
media: `
`,
bookmark: `
`,
page_view: `
`,
document: `
`,
};
// set all the predefined intervals
for (const key in tlz.intervals) {
tlz.intervals[key].interval = tlz.intervals[key].set();
}
get('/api/build-info').then(bi => {
tlz.buildInfo = bi;
});
// sets up map singleton; should be done after settings but before first page load
function initMapSingleton() {
const defaultMapboxToken = 'pk.eyJ1IjoiZHlhbmltIiwiYSI6ImNsYXNqcDVrYjF2OGwzcG1xaDB5YmlhZmQifQ.Y6QIKhjU0NeccKS6Rs8YqA';
mapboxgl.accessToken = tlz.settings?.application?.mapbox_api_key || defaultMapboxToken;
tlz.map = new mapboxgl.Map({
container: document.createElement('div'),
style: `mapbox://styles/mapbox/standard?optimized=true`,
antialias: true
});
tlz.map._container.id = 'map'; // the container element we specified above in the Map constructor is stored at tlz.map._container
tlz.map.tl_navControl = new mapboxgl.NavigationControl();
tlz.map.tl_containers = new Map(); // JS map, not geo map
tlz.map.tl_data = {
markers: [], // stores markers that are currently on the map
layers: {}, // layers currently on the map, keyed by ID
sources: {} // sources currently on the map, keyed by ID
};
tlz.map.tl_addNavControl = function() {
if (!tlz.map.hasControl(tlz.map.tl_navControl)) {
tlz.map.addControl(tlz.map.tl_navControl);
}
};
tlz.map.tl_addMarker = function(marker) {
tlz.map.tl_data.markers.push(marker);
marker.addTo(tlz.map);
};
tlz.map.tl_removeMarker = function(marker) {
const idx = tlz.map.tl_data.markers.indexOf(marker);
if (idx > -1) {
marker.remove();
tlz.map.tl_data.markers.splice(idx, 1);
}
};
tlz.map.tl_addLayer = function(layer, underLayerID) {
tlz.map.tl_data.layers[layer.id] = layer;
if (underLayerID && !tlz.map.getLayer(underLayerID)) {
underLayerID = null;
}
tlz.map.addLayer(layer, underLayerID);
};
tlz.map.tl_removeLayer = function(layerID) {
delete tlz.map.tl_data.layers[layerID];
if (tlz.map.getLayer(layerID)) {
tlz.map.removeLayer(layerID);
}
};
tlz.map.tl_addSource = function(id, source) {
tlz.map.tl_data.sources[id] = source;
tlz.map.addSource(id, source);
};
tlz.map.tl_removeSource = function(sourceID) {
delete tlz.map.tl_data.sources[sourceID];
if (tlz.map.getSource(sourceID)) {
tlz.map.removeSource(sourceID);
}
};
tlz.map.tl_clearMarkers = function() {
tlz.map.tl_data.markers.forEach(marker => marker.remove());
tlz.map.tl_data.markers = [];
}
tlz.map.tl_clear = function() {
tlz.map.tl_clearMarkers();
for (const layerID in tlz.map.tl_data.layers) {
tlz.map.tl_removeLayer(layerID);
}
for (const sourceID in tlz.map.tl_data.sources) {
tlz.map.tl_removeLayer(sourceID);
}
tlz.map.setPadding({left: 0, top: 0});
};
// tlz.map.loaded() doesn't always return true after 'load' event fires, not clear why; TODO: maybe create issue to ask?
tlz.map.tl_isLoaded = false;
tlz.map.on('load', () => tlz.map.tl_isLoaded = true);
tlz.map.once('style.load', () => {
// update the lighting every minute to match the current time of day
setInterval(updateMapLighting, 60000);
});
tlz.map.on('style.load', () => {
updateMapLighting();
// add terrain source, but don't set it on the map unless enabled
if (!tlz.map.getSource('mapbox-dem')) {
tlz.map.tl_addSource('mapbox-dem', {
type: 'raster-dem',
// TODO: what's the difference between these?
url: 'mapbox://mapbox.terrain-rgb'
// url: "mapbox://mapbox.mapbox-terrain-dem-v1"
});
}
applyTerrain();
// changing the style obliterates layers
tlz.openRepos.forEach(repo => {
if (mapData.heatmap) {
renderHeatmap();
}
if (mapData.results) {
renderMapData();
}
});
});
tlz.map.on('click', e => {
if ($('#proximity-toggle')?.classList?.contains('active')) {
$('#proximity').value = `${e.lngLat.lat.toFixed(5)}, ${e.lngLat.lng.toFixed(5)}`;
$('#proximity').dataset.lat = e.lngLat.lat;
$('#proximity').dataset.lon = e.lngLat.lng;
$('#proximity').dispatchEvent(new Event('change', { bubbles: true }));
$('#proximity-toggle').classList.remove('active');
$('.mapboxgl-canvas-container').style.cursor = '';
}
});
// Can be useful when troubleshooting zoom-related things
// tlz.map.on('zoom', function() {
// console.debug('MAP ZOOM:', tlz.map.getZoom());
// });
}
// Computes the Luxon Duration difference between ts1 and ts2 (ISO dates)
// so that the difference is always positive.
function durationBetween(ts1, ts2) {
const dt1 = DateTime.fromISO(ts1);
const dt2 = DateTime.fromISO(ts2);
return DateTime.max(dt1, dt2).diff(DateTime.min(dt1, dt2));
}
// Converts a JS Date object to Unix timestamp in seconds.
function dateToUnixSec(d) {
return Math.floor(d.getTime() / 1000);
}
// Converts Unix timestamp in seconds to a JS Date.
function unixSecToDate(ts) {
return new Date(ts * 1000);
}
// Renders a dropdown for a filter input inside containerEl, with the given
// title, loading data from storage using loadKey.
function renderFilterDropdown(containerEl, title, loadKey) {
const staticMenu = containerEl.classList.contains('static-menu')
const tplSel = staticMenu ? '#tpl-filter-dropdown-static' : '#tpl-filter-dropdown';
const tpl = cloneTemplate(tplSel);
const titleElem = staticMenu ? $('h6', tpl) : $('button', tpl);
titleElem.innerText = title;
const menu = staticMenu ? tpl : $('.dropdown-menu', tpl);
// append select-all/none toggles
menu.append(cloneTemplate('#tpl-dropdown-toggles'));
// append the data sources
const data = load(loadKey);
if (title == "Types") {
data[""] = {title: "Unknown"};
}
for (const key in data) {
const elem = cloneTemplate('#tpl-dropdown-checkbox');
$('input', elem).value = key;
elem.append(document.createTextNode(data[key].title || data[key].labels[0]));
menu.append(elem);
}
containerEl.replaceChildren(tpl);
}
async function newDataSourceSelect(selectEl, options) {
if ($(selectEl).tomselect) {
return $(selectEl).tomselect;
}
var dsList = await app.DataSources();
for (const ds of dsList) {
const optEl = document.createElement('option');
optEl.value = ds.name;
optEl.innerText = ds.title;
optEl.dataset.customProperties = ``;
$(selectEl).append(optEl);
}
function renderTomSelectItemAndOption(data, escape) {
if (data.customProperties) {
return `
${data.customProperties}${escape(data.text)}
`;
}
return `
${escape(data.text)}
`;
}
const ts = new TomSelect($(selectEl), {
maxItems: options?.maxItems,
render: {
item: renderTomSelectItemAndOption,
option: renderTomSelectItemAndOption
}
});
// for a single-select control, it usually makes sense to
// initialize empty rather than the first option
if (options?.maxItems == 1) {
ts.clear(true);
}
// Clear input after selecting matching option from list
// (I have no idea why this isn't the default behavior)
ts.on('item_add', () => ts.control_input.value = '' );
return ts;
}
function setDateInputPlaceholder(containerEl) {
const dateInput = $('.date-input', containerEl);
if (dateInput.datepicker.opts.range) {
dateInput.placeholder = "Date range";
} else {
dateInput.placeholder = "Date";
}
const sortInput = $('.date-sort', containerEl);
if (sortInput?.value == "NEAR") {
dateInput.placeholder = "Target date";
}
}
function newDatePicker(opts) {
const tpl = cloneTemplate('#tpl-datepicker');
if (!opts.vertical) {
$('label', tpl).remove();
tpl.classList.remove('mb-3');
$$('.mb-3', tpl).forEach(el => el.classList.remove('mb-3'));
}
if (!opts.proximity) {
$('option[value="NEAR"]', tpl).remove();
}
if (opts.sort === false) {
$('.sort-container', tpl).closest('.input-group').classList.remove('input-group', 'flex-nowrap');
$('.sort-container', tpl).remove();
} else {
if (!opts.defaultSort) {
opts.defaultSort = opts.range ? "DESC" : "ASC";
}
if (opts.defaultSort) {
$('.date-sort', tpl).value = opts.defaultSort;
}
}
// used for firing change events
const dateInputElem = $('.date-input', tpl)
let lastVal = dateInputElem.value;
// set up the AirDatepicker options
const dpOpts = {
...opts.passthru,
container: opts?.passthru?.container || tpl, // this is necessary if the tag is fixed (doesn't scroll), so we choose a container that will scroll with the input element
locale: {
days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
daysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
daysMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
monthsShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
today: 'Today',
clear: 'Clear',
dateFormat: 'MM/dd/yyyy',
timeFormat: 'hh:mm aa',
firstDay: 0
},
buttons: opts?.passthru?.buttons || [],
multipleDatesSeparator: opts?.passthru?.multipleDatesSeparator || ' - ',
navTitles: opts?.passthru?.navTitles || {
days: 'MMMMyyyy',
months: 'yyyy'
},
onShow(isFinished) {
if (isFinished) return false;
lastVal = dateInputElem.value;
},
onHide(isFinished) {
// gets called twice apparently (!?) once when it has started
// being hidden and once when it has finished being hidden
if (isFinished) return;
if (dateInputElem.value != lastVal) {
lastVal = dateInputElem.value;
trigger(dateInputElem, 'change');
}
}
};
if (opts.time) {
dpOpts.timepicker = true;
}
if (opts.timeToggle) {
dpOpts.buttons.push({
className: 'datepicker-time-toggle',
content(dp) {
return dp.opts.timepicker ? 'Disable time' : 'Enable time'
},
onClick(dp) {
let viewDate = dp.viewDate;
let today = new Date();
// Since timepicker takes initial time from 'viewDate', set up time here,
// otherwise time will be equal to 00:00 if user navigated through datepicker
viewDate.setHours(today.getHours());
viewDate.setMinutes(today.getMinutes());
dp.update({
timepicker: !dp.opts.timepicker,
viewDate
});
}
});
}
if (opts.rangeToggle) {
dpOpts.buttons.push({
content(dp) {
return dp.opts.range ? 'Disable range' : 'Enable range';
},
onClick(dp) {
// toggle range support in date picker, and deselect second date
// if range is now disabled but had both start and end date selected
dp.update({ range: !dp.opts.range });
if (!dp.opts.range && dp.selectedDates.length > 1) {
dp.unselectDate(dp.selectedDates[1]);
}
// update sort input, as we can't do NEAR for ranges
const sortEl = $('.date-sort', tpl);
if (sortEl) {
if (sortEl.value == "NEAR") {
sortEl.value = "DESC";
}
if (!dp.opts.range) {
sortEl.value = "ASC";
}
if ($('[value="NEAR"]', sortEl)) {
$('[value="NEAR"]', sortEl).disabled = !!dp.opts.range;
}
trigger(sortEl, 'change');
}
}
});
}
// prefer the "Clear" and "Apply" buttons to go at the end
dpOpts.buttons.push(
'clear',
);
if (!opts.noApply) {
dpOpts.buttons.push({
content() {
return 'Apply';
},
onClick(dp) {
dp.hide();
}
});
}
$('.date-input', tpl).datepicker = new AirDatepicker($('.date-input', tpl), dpOpts);
setDateInputPlaceholder(tpl);
return tpl;
}
///////////////////
// FILE PICKER
///////////////////
async function newFilePicker(name, options) {
const filePicker = cloneTemplate('#tpl-file-picker');
filePicker.options = options; // keeps track of its configuration
filePicker.filepaths = {}; // keeps track of which files are currently selected
filePicker.selected = function() { return Object.keys(filePicker.filepaths); };
filePicker.lastPathInput = ""; // the value of the path textbox, used to debounce the file listing updates
// restore the hidden files preference, if set
$('.file-picker-hidden-files', filePicker).checked = tlz.filePickers?.[name]?.show_hidden;
// we don't calculate sizes of directories, so hide size column if we only show dirs
if (filePicker.options?.only_dirs) {
$('.file-picker-col-size', filePicker).remove();
}
$('.file-picker-mount-points', filePicker).innerHTML = 'Mount points';
// populate file picker roots dropdown
const roots = await app.FileSelectorRoots();
for (const root of roots) {
const a = document.createElement('a');
a.classList.add('dropdown-item', 'd-flex');
// insert an appropriate icon based on the root type
const icons = {
"home": `
`,
"root": `
`,
"mount": `
`,
"logical": `
`,
};
a.innerHTML = icons[root.type] || ``;
const b = document.createElement('b');
const secondary = document.createElement('div');
b.classList.add('me-2');
b.innerText = root.label;
secondary.classList.add('text-secondary', 'small');
secondary.innerText = root.path;
a.append(b);
a.append(secondary);
a.dataset.filepath = root.path;
$('.file-picker-mount-points', filePicker).append(a);
}
filePicker.navigate = async function (dir = tlz.filePickers?.[name]?.dir, options) {
// merge navigate options with those specified for the file picker
options = {...filePicker.options, ...options}
// as a special case, show_hidden is an option that is preserved
// across instances of this file picker, so if it's not explicitly
// set, use the preserved value
if (!("show_hidden" in options) && tlz.filePickers?.[name]?.show_hidden) {
options.show_hidden = true;
}
// don't get new file listing if it's the same dir and refresh isn't forced
if (filePicker.dir && dir == filePicker.dir && !options?.refresh) {
return;
}
const listing = await app.FileListing(dir, options);
// only navigate if the location is different or refresh is forced
if (filePicker.dir && listing.dir == filePicker.dir && !options?.refresh) {
return;
}
filePicker.dir = listing.dir;
tlz.filePickers[name] = {
dir: listing.dir,
show_hidden: options?.show_hidden
}
// let listeners know we are navigating
const event = new CustomEvent("navigate", {
bubbles: true,
detail: {
dir: listing.dir,
options: options,
selectedItem: $('.file-picker-table .file-picker-item.selected', filePicker)
}
});
filePicker.dispatchEvent(event);
// reset the filepath box, listing table, and selected path(s),
// then emit event (intentionally named uniquely from standard events)
if (!options?.autocomplete) {
$('.file-picker-path').value = listing.dir;
}
$('.file-picker-table tbody', filePicker).innerHTML = '';
filePicker.filepaths = {};
filePicker.dispatchEvent(new CustomEvent("selection", { bubbles: true }));
// show "Up" at the top if that's doable
if (listing.up) {
$('.file-picker-up', filePicker).style.display = '';
$('.file-picker-up', filePicker).dataset.filepath = listing.up; // HTML-safe
} else {
$('.file-picker-up', filePicker).style.display = 'none';
}
// render each file to the listing
let selectedItem;
for (const item of listing.files) {
const modDate = DateTime.fromISO(item.mod_time);
const row = cloneTemplate('#tpl-file-picker-item');
if (options?.only_dirs) {
$('.file-picker-col-size', row).remove();
} else if (!item.is_dir) {
$('.sort-size', row).innerText = humanizeBytes(item.size);
}
$('.sort-name', row).innerHTML = item.is_dir ?
`` :
``;
$('.sort-name', row).append(document.createTextNode(item.name));
$('.sort-modified', row).innerText = modDate.toRelative(); //modDate.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS);
row.dataset.filepath = item.full_name;
row.classList.add(item.is_dir ? "file-picker-item-dir" : "file-picker-item-file");
// add data attributes for reliable sorting that doesn't depend on displayed contents
$('.sort-name', row).dataset.name = item.name || "";
if ($('.sort-size', row)) {
$('.sort-size', row).dataset.size = item.is_dir ? "" : (item.size || "");
}
$('.sort-modified', row).dataset.modified = modDate.toUnixInteger() || "";
// add row to list
$('.file-picker-table tbody', filePicker).append(row);
// keep track of selected item, if this is it
if (item.name == listing.selected) {
selectedItem = row;
}
}
// scroll to selected item, if any; otherwise reset scroll to top
if (selectedItem) {
selectedItem.click();
selectedItem.scrollIntoView(true);
} else {
$('.file-picker-table', filePicker).scrollTop = 0;
}
// enable sorting: first reset previous sort, then initialize or re-index list
$$('.file-picker-table .table-sort', filePicker).forEach(elem => {
elem.classList.remove('desc', 'asc');
});
if (!filePicker.listjs) {
// this binds event handlers; only do once per file picker
filePicker.listjs = new List($('.file-picker-table', filePicker), {
listClass: 'table-tbody',
sortClass: 'table-sort',
valueNames: [
{ name: 'sort-name', attr: 'data-name' },
{ name: 'sort-size', attr: 'data-size' },
{ name: 'sort-modified', attr: 'data-modified' }
]
});
} else {
filePicker.listjs.reIndex();
}
};
filePicker.navigate();
return filePicker;
}
// when an item in the file picker is clicked
on('click', '.file-picker-table .file-picker-item', event => {
// ignore after first click
if (event.detail > 1) {
return;
}
// TODO: multi-select (if enabled) - use event.shiftKey or event.ctrlKey to detect
const fp = event.target.closest('.file-picker');
// select the clicked one, if not already selected
const item = event.target.closest('.file-picker-item');
if (item.classList.contains('selected')) {
return;
}
// deselect any previous items
$$('.selected', fp).forEach(el => el.classList.remove('selected'));
fp.filepaths = {};
item.classList.toggle('selected');
fp.filepaths[item.dataset.filepath] = true;
// event intentionally named differently from standard events like 'change' and 'select' to disambiguate
fp.dispatchEvent(new CustomEvent("selection", { bubbles: true }));
});
// as the user types or pastes a path, navigate to what they've typed, and filter results too
on('keyup change paste', '.file-picker-path', async event => {
const fp = event.target.closest('.file-picker');
const pathInput = event.target.value;
if (pathInput == fp.lastPathInput) {
return;
}
fp.lastPathInput = event.target.value;
console.log("EVENT:", event, pathInput);
fp.navigate(event.target.value, { autocomplete: true })
});
// navigate when double-clicking a folder
on('dblclick', '.file-picker-table .file-picker-item-dir', event => {
const item = event.target.closest('.file-picker-item');
// TODO: if dir, nav; if file, select...look for "file-picker-item-dir|file" classes
const dir = item.dataset.filepath;
event.target.closest('.file-picker').navigate(dir);
});
// go up a folder
on('click', '.file-picker-table .file-picker-up', event => {
const dir = event.target.closest('.file-picker-up').dataset.filepath;
event.target.closest('.file-picker').navigate(dir);
});
// choosing a mount
on('click', '.file-picker-mount-points .dropdown-item', event => {
event.target.closest('.file-picker').navigate(event.target.closest('.dropdown-item').dataset.filepath);
});
// toggle hidden files/folders
on('change', '.file-picker-hidden-files', event => {
const fp = event.target.closest('.file-picker');
if (!fp.options) {
fp.options = {};
}
fp.options.refresh = true; // dir isn't changing, but we need to update the listing
fp.options.show_hidden = event.target.checked;
event.target.closest('.file-picker').navigate();
});
function entityPicture(entity) {
if (entity.picture.startsWith("http://") || entity.picture.startsWith("https://")) {
return entity.picture;
}
let entityPicture = `/repo/${tlz.openRepos[0].instance_id}/${entity.picture}`;
if (entity.forceUpdate) {
entityPicture += `?nocache=${new Date().getTime()}`; // classic cachebuster trick
}
return entityPicture;
}
// TODO: consider changing second param to preview=false, so that by default
// we show the thumbnail (it's more common), and you turn on showing the more
// expensive preview image
// TODO: rename this to something else -- also used for thumbnail videos...
function itemImgSrc(item, thumbnail = false) {
if (!item.data_file) {
return "";
}
if (item.data_file.startsWith("http://") || item.data_file.startsWith("https://")) {
return item.data_file;
}
if (item.data_type == "image/gif") {
thumbnail = false; // just show the actual gif
}
const params = new URLSearchParams({
data_id: item.data_id || "",
data_file: item.data_file,
data_type: item.data_type
});
const ext = thumbnail ? "" : ".avif"; // thumbnail extension is dictated by server
return `/repo/${item.repo_id}/${thumbnail ? "thumbnail" : "image"}/${item.id ? item.id+ext : ""}?${params.toString()}`;
}
function entityDisplayNameAndAttr(entity) {
const unknown = "(unknown)";
const attrValue = entity?.attribute?.alt_value || entity?.attribute?.value;
const result = {
name: entity?.name || attrValue || unknown,
attribute: entity?.attribute?.name == "_entity" ? "" : attrValue
};
if (result.attribute == result.name) {
// this happens sometimes if a data source ONLY provides a name,
// then generally the attribute will have the same value as name
result.attribute = "";
}
return result;
}
function avatarColorClasses(i) {
const colorClass = tlz.colorClasses[i % tlz.colorClasses.length];
const classes = ["bg-"+colorClass];
if (colorClass && !colorClass?.endsWith("-lt")) {
classes.push("text-white");
}
return classes;
}
function avatar(colored, entity, classes) {
if (entity?.picture) {
return ``;
}
if (colored) {
classes += " " + avatarColorClasses(entity?.id).join(" ");
}
const userIcon = ``;
return `${initials(entity?.name) || userIcon}`;
}
function initials(name) {
if (!name) return "";
// match only sequences of alphanumeric characters
const regex = /[A-Za-z0-9]+/g;
let matches = name.split('@')[0].match(regex); // only consider username portion of email addresses
if (matches) {
name = matches.join(" ").trim();
}
// as of March 2023, this was cutting-edge, but now all major browsers support this API
// Emoji-aware split(): https://stackoverflow.com/a/71619350/1048862
// Detecting emoji: https://stackoverflow.com/a/64007175/1048862
if (Intl.Segmenter) {
const splitEmoji = (string) => [...new Intl.Segmenter().segment(string)].map(x => x.segment);
const runes = splitEmoji(name);
const nonEmojiRunes = runes.filter(rune => !/\p{Extended_Pictographic}/u.test(rune));
name = nonEmojiRunes.join("").trim();
}
let initials = name.split(" ").map((n) => n[0]).join("") || "";
if (initials.length > 2) {
initials = initials[0] + initials[initials.length-1];
}
return initials.trim();
}
function maxlenStr(str, maxLen) {
return (str || "").length > maxLen ? str.substring(0, maxLen).trim() + "..." : str;
}
function humanizeBytes(size) {
if (size == null) {
return "";
}
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'][i];
}
// for pages like /[entities|items|jobs]/uuid/rowID
function parseURIPath() {
let parts = window.location.pathname.split('/');
return {
repoID: parts[2],
rowID: Number(parts[3])
};
}
// itemTimestampDisplay returns timestamp display for the item,
// taking into account timestamp, timespan, and timeframe fields.
// If endItem is specified, its time info is used to create a time
// span from item through endItem. Both items must have a timestamp.
function itemTimestampDisplay(item, endItem) {
let result = {};
if (!item.timestamp) {
return result;
}
if (endItem == item) {
endItem = undefined;
}
// setZone: true will show the time in the local time zone if set,
// which makes the most sense for most places in our application
const dt = DateTime.fromISO(item.timestamp, { setZone: true });
result.relative = dt.toRelative();
if (item.timeframe) {
// find the level of precision by looking at each major compontent of the timestamps
const tf = DateTime.fromISO(item.timeframe, { setZone: true });
const itvl = Interval.fromDateTimes(dt, tf);
// hasSame() is a weirdly-named function, since it seems to actually return true
// at the first unit that DIFFERS from start to end.
if (itvl.hasSame('hour')) {
result = {
dateTime: `${dt.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)} ${dt.toLocaleString(DateTime.TIME_WITH_SECONDS)}`,
weekdayDate: `${dt.weekdayLong}, ${dt.toLocaleString(DateTime.DATE_MED)}`,
dateWithWeekday: `${dt.toLocaleString(DateTime.DATE_MED)} (${dt.weekdayLong})`,
time: `${dt.hour % 12} o'clock` // TODO: have it be like "1pm" instead of "13 o'clock"
};
result.timeWithZone = `${result.time} ${dt.toFormat("ZZZZ")}`;
} else {
if (itvl.hasSame('day')) {
result.dateTime = `${dt.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`;
result.weekdayDate = `${dt.weekdayLong}, ${dt.toLocaleString(DateTime.DATE_MED)}`,
result.dateWithWeekday = `${dt.toLocaleString(DateTime.DATE_MED)} (${dt.weekdayLong})`;
} else if (itvl.hasSame('month')) {
result.dateTime = `${dt.monthLong} ${dt.year}`;
result.weekdayDate = `${dt.monthLong} ${dt.year}`,
result.dateWithWeekday = `${dt.monthLong} ${dt.year}`;
} else if (itvl.hasSame('year')) {
result.dateTime = `${dt.year}`;
result.weekdayDate = `${dt.year}`;
result.dateWithWeekday = `${dt.year}`;
}
}
} else {
result = {
dateTime: `${dt.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY)} ${dt.toFormat("ZZZZ")}`,
weekdayDate: `${dt.weekdayLong}, ${dt.toLocaleString(DateTime.DATE_MED)}`,
dateWithWeekday: `${dt.toLocaleString(DateTime.DATE_MED)} (${dt.weekdayLong})`,
time: dt.toLocaleString(DateTime.TIME_SIMPLE),
};
result.timeWithZone = `${result.time} ${dt.toFormat("ZZZZ")}`;
}
const timespan = endItem?.timespan || endItem?.timestamp || item.timespan;
if (timespan) {
const tspanDt = DateTime.fromISO(timespan);
const itvl = Interval.fromDateTimes(dt, tspanDt);
result.dateTime = itvl.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY);
result.time = itvl.toLocaleString(DateTime.TIME_SIMPLE);
// TODO: calculate the duration... then also make sure the returned values indicate a timespan
}
return result;
}
////////////////////////////////////////////////////
// Item display & rendering
////////////////////////////////////////////////////
function itemContentElement(item, opts) {
function noContentElem() {
let noContent = document.createElement('div');
noContent.innerText = "Item has no content";
noContent.classList.add('content', 'd-flex', 'justify-content-center', 'align-items-center', 'bg-muted-lt', 'py-5');
return noContent;
}
if (item?.data_type?.startsWith("text/"))
{
const container = document.createElement('div');
container.classList.add('content');
container.dataset.contentType = "text";
function renderTextData(elem, text) {
if (opts?.maxLength) {
text = maxlenStr(text, opts.maxLength);
}
if (item.data_type.startsWith("text/plain")) {
elem.innerText = text;
} else if (item.data_type.startsWith("text/markdown")) {
elem.innerHTML = DOMPurify.sanitize(marked.parse(text));
} else if (item.data_type.startsWith("text/html")) {
const iframe = document.createElement('iframe');
if (item.data_file) {
iframe.src = `/repo/${item.repo_id}/${item.data_file}`;
} else if (item.data_text) {
iframe.srcdoc = DOMPurify.sanitize(truncated);
}
elem.append(iframe);
}
}
if (item.data_text) {
renderTextData(container, item.data_text);
} else if (item.data_file) {
// render HTML files directly into an iframe using its URL,
// but other text data we download and truncate/parse
if (item.data_type.startsWith("text/html")) {
renderTextData(container);
} else {
fetch(`/repo/${item.repo_id}/${item.data_file}`)
.then((response) => response.text())
.then((text) => {
renderTextData(container, text);
});
}
} else {
return noContentElem();
}
return container;
}
else if (item.data_file || item.data_hash || item.data_id)
{
const wrapInLoaderContainer = function(container, mediaTag, readyEvent) {
const loaderSupercontainer = cloneTemplate('#loader-container');
$('.loading-message', loaderSupercontainer).innerText = "Rendering preview";
// prepend the img tag to the supercontainer since it's (probably)
// lazy-loaded, and it has to be on the DOM to try loading -- then
// we can fade out the loader
loaderSupercontainer.prepend(mediaTag);
container.append(loaderSupercontainer);
// when the image has loaded, replace loader element with the image
// (img tags might be unset if obfuscation is enabled)
mediaTag?.addEventListener(readyEvent, function() {
// In case the caller added classes to the returned element (the container),
// we will need to add those to the mediaTag since it will replace the container.
container.classList.forEach(name => mediaTag.classList.add(name));
$('.loader-container', loaderSupercontainer).classList.add('fade-out');
setTimeout(function() {
$('.loader-container', loaderSupercontainer).remove();
}, 1000);
});
}
if (item.data_type.startsWith("image/"))
{
// initial image tag (or placeholder element, if processing) to return
const makeImgTag = function(src) {
if (opts?.avatar) {
const span = document.createElement('span');
span.dataset.contentType = "image";
span.classList.add("avatar", "avatar-xl", "m-1");
span.style.backgroundImage = `url('${src}')`;
return span;
} else {
const imgTag = new Image;
imgTag.dataset.contentType = "image";
imgTag.classList.add("content");
if (src) {
// there won't be a src if app is in obfuscation mode
imgTag.src = src;
}
if (!opts?.noLazyLoading) {
imgTag.loading = "lazy";
}
// TODO: handle image errors better
imgTag.addEventListener('error', function(err) {
console.error("Loading image failed:", err);
});
return imgTag;
}
};
// make the img tag that will display the image; depending on some things
// we may need to show a thumbhash or a loading indicator first, however,
// as we want to support a lot of formats even if browsers don't natively;
// this conversion can take some time - Note: HEIF images are extremely
// common with iPhones, yet even Apple's own browser can't display them.
// (2023 UPDATE: That may be resolved in 2024)
// Note that an img tag can have an empty src attribute if obfuscation is
// enabled in the app; in which case we just show the thumbhash instead
const imgSrc = itemImgSrc(item, opts?.thumbnail);
let imgTag;
if (imgSrc) {
imgTag = makeImgTag(imgSrc);
}
// remember, the actual img tag might not be used if the app is in obfuscation mode,
// in which case we might just display the thumbhash image
//
// if the image has been cached or already loaded recently, it might already
// be available; if so, skip the complicated stuff
if (imgTag && ((imgTag.src && imgTag.complete) || opts?.avatar)) {
const container = document.createElement('div');
container.classList.add('thumbhash-container');
container.append(imgTag);
return container;
}
// the container will hold either the thumbhash and the final image, or
// the loading indicator. If it holds the loading indicator, it gets
// replaced with the img tag when the image is done loading
const container = document.createElement('div');
// if a thumbhash is available, render it while we load the full image/thumbnail
if (item.thumb_hash) {
container.classList.add('thumbhash-container');
const thumbHashWithAspectRatio = decodeBase64ToBytes(item.thumb_hash);
const aspectRatioBytes = thumbHashWithAspectRatio.subarray(0, 4);
const thumbHash = thumbHashWithAspectRatio.subarray(4);
const aspectRatio = new DataView(aspectRatioBytes.buffer).getFloat32(0);
const obfuscate = tlz.settings?.application?.obfuscation?.enabled;
const thumbhashImgTag = makeImgTag(thumbHashToDataURL(thumbHash));
thumbhashImgTag.classList.add('thumbhash');
thumbhashImgTag.classList.remove('content');
if (!obfuscate) {
thumbhashImgTag.classList.add('breathing'); // this indicates it's loading, which it's really not when in demo mode
}
container.style.aspectRatio = aspectRatio;
thumbhashImgTag.style.aspectRatio = aspectRatio;
container.append(thumbhashImgTag);
if (imgTag && !obfuscate) {
// getting the encoded image will take some time: hide the img tag until it's
// done loading, and when it's done, swap the placeholder for, or show, the image
imgTag.classList.add('invisible');
// when the image has loaded, fade in the picture over the thumbhash
imgTag.addEventListener('load', function() {
imgTag.classList.add('fade-in');
imgTag.classList.remove('invisible');
// TODO: This is used on the item page where the image may be replaced by the motionpic...
setTimeout(function() {
imgTag.classList.remove('fade-in');
thumbhashImgTag.classList.remove('breathing');
}, 1000);
});
container.append(imgTag);
}
} else if (imgTag) {
wrapInLoaderContainer(container, imgTag, 'load');
}
return container;
}
else if (item.data_type.startsWith("video/"))
{
// TODO: thumbhashes for videos too? (we'd have to generate an image thumbnail in the process)
const makeVideoTag = function(sources) {
const videoTag = document.createElement('video');
videoTag.dataset.contentType = "video";
videoTag.classList.add('content', 'w-100');
if (opts?.thumbnail) {
videoTag.classList.add('video-thumbnail');
}
if (opts?.controls !== false && !opts?.thumbnail) {
videoTag.controls = true;
}
if (opts?.autoplay) {
videoTag.muted = true;
videoTag.autoplay = true;
}
videoTag.loop = true;
// videoTag.src = src; // TODO: this breaks Safari with our method! Safari sends a Range request for 2 bytes to probe the Content-Type header, but if we use and specify it then it works
for (const source of sources) {
const sourceTag = document.createElement('source');
sourceTag.src = source.src;
sourceTag.type = source.type;
videoTag.append(sourceTag);
}
// TODO: error handling
// videoTag.addEventListener("error", e => {
// switch (e.target.error.code) {
// case e.target.error.MEDIA_ERR_ABORTED:
// alert('You aborted the video playback.');
// break;
// case e.target.error.MEDIA_ERR_NETWORK:
// alert('A network error caused the video download to fail part-way.');
// break;
// case e.target.error.MEDIA_ERR_DECODE:
// alert('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.');
// break;
// case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
// alert('The video could not be loaded, either because the server or network failed or because the format is not supported.');
// break;
// default:
// alert('An unknown error occurred.');
// break;
// }
// });
const container = document.createElement('div');
wrapInLoaderContainer(container, videoTag, 'canplay');
return container;
};
// // 3GPP files are common among text messages.
// if (item.data_type == "video/3gpp") {
// const loader = cloneTemplate('#loader-container');
// $('.loading-message', loader).innerText = "Transcoding";
// // TODO: if conversion fails, we should show or do something productive
// app.VideoToMP4(item.repo_id, item.data_file).then(mp4 => {
// loader.replaceWith(makeVideoTag("data:video/mp4;base64,"+mp4));
// });
// return loader;
// }
if (item.data_file) {
if (opts?.thumbnail) {
return makeVideoTag([ {src: itemImgSrc(item, true), type: 'video/webm'} ]);
} else {
// prefer original video if browser supports it, otherwise they will have to choose the transcode
return makeVideoTag([
{src: `/repo/${item.repo_id}/${item.data_file}`, type: item.data_type},
{src: `/repo/${item.repo_id}/transcode/${item.data_file}`, type: 'video/webm'}
]);
}
} else {
// TODO: this means that obfuscation mode is enabled: how to blur the video?? (we could use CSS I suppose... but I don't like leaving it up to the front-end)
// TODO: blur the video with this ffmpeg flag: -vf "boxblur=50:5" (box blur, value 50, applied 5 times)
return noContentElem();
}
}
else if (item.data_type.startsWith("audio/") || item.data_type == "application/ogg")
{
const audioTag = document.createElement('audio');
audioTag.classList.add('content');
audioTag.dataset.contentType = "audio";
audioTag.setAttribute('controls', '');
audioTag.src = `/repo/${item.repo_id}/${item.data_file}`;
return audioTag;
}
else if (item.data_type == "application/pdf")
{
const objectTag = document.createElement('object');
objectTag.type = item.data_type;
objectTag.data = `/repo/${item.repo_id}/${item.data_file}#zoom=FitH`;
objectTag.innerHTML = "
Could not render PDF.
";
const divContainer = document.createElement('div');
divContainer.classList.add('ratio', 'ratio-4x3');
divContainer.append(objectTag);
return divContainer;
}
else
{
const elem = noContentElem();
elem.innerText = "Unsupported format";
if (item.data_type) {
elem.innerText += ` (${item.data_type})`;
}
return elem;
}
}
else if (item.latitude && item.longitude)
{
const container = cloneTemplate('#map-container');
container.dataset.contentType = "location";
// size the container because the map won't stretch it, it will only expand to fill space
container.classList.add('ratio-16x9');
tlz.map.tl_containers.set(container, function() {
tlz.map.tl_addNavControl();
const marker = new mapboxgl.Marker().setLngLat([item.longitude, item.latitude]);
tlz.map.tl_addMarker(marker);
tlz.map.flyTo({center: marker.getLngLat(), zoom: 16});
});
return container;
}
else
{
return noContentElem();
}
}
// itemMiniDisplay returns a populated, ready-to-render DOM element
// that comprises a mini-display or preview of the items, which must
// be an array of items that are all the same classification; or at
// least, that are to be rendered the same way as the first item.
function itemMiniDisplay(items, options) {
let representative = items[0];
switch (representative.classification) {
case 'message':
case 'email':
case 'social':
return miniDisplayMessages(items);
case 'media':
return miniDisplayMedia(items, options);
case 'location':
return miniDisplayLocations(items, options);
case 'document':
case 'note':
case 'bookmark':
return miniDisplayBookmark(items);
case 'page_view':
return miniDisplayPageView(items);
case 'event':
return miniDisplayEvent(items);
default:
console.warn("TODO: UNSUPPORTED ITEM CLASS:", representative.classification, items);
return miniDisplayMisc(items);
}
}
function miniDisplayLocations(items, options) {
const minidisp = {
icon: ``,
iconColor: 'cyan'
}
if (options?.noMap) {
return minidisp;
}
const ratioElem = cloneTemplate('#map-container');
ratioElem.classList.add('ratio-16x9', 'rounded', 'overflow-hidden');
const mapRenderFn = function () {
tlz.map.tl_addNavControl();
// add each marker to the map and build bounding box as we go
const bounds = new mapboxgl.LngLatBounds();
for (const item of items) {
const marker = new mapboxgl.Marker()
.setLngLat([item.longitude, item.latitude]);
// TODO: make useful popups
// .setPopup(
// new mapboxgl.Popup({ offset: 25 }).setHTML(info)
// )
tlz.map.tl_addMarker(marker);
bounds.extend([item.longitude, item.latitude]);
}
tlz.map.fitBounds(bounds, { padding: 75, maxZoom: 16 }); // set `duration: 0` in the options to disable animation/easing
};
tlz.map.tl_containers.set(ratioElem, mapRenderFn);
minidisp.element = ratioElem;
return minidisp;
}
function miniDisplayMedia(items, options) {
const container = cloneTemplate('#tpl-media-card');
for (const item of items) {
const a = document.createElement('a');
a.href = `/items/${item.repo_id}/${item.id}`;
const elem = itemContentElement(item, { options, thumbnail: true });
elem.classList.add('h-100', 'rounded');
a.append(elem);
container.append(a);
}
if (items.length == 1) {
container.classList.add('minidisp-media-xl');
} else if (items.length <= 3) {
container.classList.add('minidisp-media-l');
} else if (items.length >= 4 && items.length < 7) {
container.classList.add('minidisp-media-m');
}
return {
icon: `
`,
iconColor: 'pink',
element: container,
};
}
function miniDisplayPageView(items) {
const container = document.createElement('div');
for (const item of items) {
container.append(renderPageViewItem(item));
}
return {
icon: `
`,
iconColor: 'purple',
element: container,
};
}
function renderPageViewItem(item) {
const el = document.createElement('div');
el.classList.add('page_view', 'page_view-fold');
el.append(itemContentElement(item, {
maxLength: 1024, // we don't want to show an entire big file on a mini-display
}));
return el;
}
function miniDisplayBookmark(items) {
const container = document.createElement('div');
for (const item of items) {
container.append(renderBookmarkItem(item));
}
return {
icon: `
`,
iconColor: 'lime',
element: container,
};
}
function renderBookmarkItem(item) {
const container = document.createElement('div');
container.classList.add('card');
const cardBody = document.createElement('div');
cardBody.classList.add('card-body', 'margin-for-ribbon-top-left');
cardBody.append(itemContentElement(item, {
maxLength: 1024, // we don't want to show an entire big file on a mini-display
}));
const ribbonEl = document.createElement('div');
ribbonEl.classList.add('ribbon', 'ribbon-top', 'ribbon-start', 'ribbon-bookmark');
container.append(cardBody, ribbonEl);
return container;
}
function miniDisplayPaper(items) {
const container = document.createElement('div');
for (const item of items) {
container.append(renderPaperItem(item));
}
return {
icon: `
`,
iconColor: 'lime',
element: container,
};
}
function renderPaperItem(item) {
const el = document.createElement('div');
el.classList.add('paper', 'paper-fold');
el.append(itemContentElement(item, {
maxLength: 1024, // we don't want to show an entire big file on a mini-display
}));
return el;
}
function miniDisplayEvent(items) {
const container = document.createElement('div');
for (const item of items) {
container.append(itemContentElement(item, {
maxLength: 1024,
}));
}
return {
icon: ``,
iconColor: 'red',
element: container,
};
}
function miniDisplayMisc(items) {
const container = document.createElement('div');
for (const item of items) {
container.append(itemContentElement(item, {
maxLength: 1024, // we don't want to show an entire big file on a mini-display
}));
}
return {
icon: tlz.itemClassIconAndLabel().icon,
iconColor: 'gray',
element: container,
};
}
function miniDisplayMessages(items) {
const card = document.createElement('div');
card.classList.add('card');
const cardBody = document.createElement('div');
cardBody.classList.add('chat', 'card-body');
const chatBubbles = document.createElement('div');
chatBubbles.classList.add('chat-bubbles');
for (const item of items) {
chatBubbles.append(renderMessageItem(item, {withToRelations: true}));
}
cardBody.append(chatBubbles);
card.append(cardBody);
return {
icon: `
`,
iconColor: 'indigo',
element: card,
};
}
function renderMessageItem(item, options) {
function recipients(item) {
const sendersContainer = document.createElement('div');
for (const rel of item.related) {
if ((rel.label != 'sent' && rel.label != 'cc') || !rel.to_entity)
continue;
const entityEl = document.createElement('div');
entityEl.classList.add('fw-bold');
entityEl.innerText = rel.to_entity.name || rel.to_entity.attribute.value;
sendersContainer.append(entityEl);
}
return sendersContainer;
}
// sometimes, a message may have no content and only have attachments
// (this is unfortunate, but often seems to be the case with iMessage;
// we can't just delete the 'parent' item because it has an ID that
// is referenced in various places)
// in that case, we can at least promote the first attachment in the
// display of the message so that it becomes the main content of the
// message, and any other attachments remain attached, without an
// empty item cluttering the view; this is what most chat/messaging
// apps do anyway, including iMessage
if (!item.data_text && !item.data_file && item.related) {
for (const [i, rel] of item.related.entries()) {
if (rel.label == 'attachment' && rel.to_item) {
// promote first attachment, so remove it from the related items list
item.related.splice(i, 1);
// combine any related items of the empty item with that of the related item
rel.to_item.related = rel.to_item.related ? item.related.concat(rel.to_item.related) : item.related;
// combine metadata
if (item.metadata && rel.to_item.metadata) {
rel.to_item.metadata = {...rel.to_item.metadata, ...item.metadata};
} else if (item.metadata) {
rel.to_item.metadata = item.metadata;
}
// finally, replace the item with the related item that we spliced out
item = rel.to_item;
break;
}
}
}
const elem = cloneTemplate('#tpl-message');
$('.message-sender', elem).innerText = item.entity?.name || item.entity?.attribute?.value;
if (item.entity?.id == 1) {
// if current user (presumably, entity ID 1 -- though this could change later) is
// the sender, then put their own chats along the right, I guess, with the avatar
// on the outside (far right)
$('.align-items-top', elem).classList.add('flex-row-reverse');
$('.chat-bubble', elem).classList.add('chat-bubble-me');
}
const dt = DateTime.fromISO(item.timestamp, { setZone: true });
$('.message-timestamp', elem).innerText = `${dt.toLocaleString(DateTime.DATETIME_MED)} ${dt.toFormat("ZZZZ")}`;
$('.message-avatar', elem).innerHTML = avatar(true, item.entity);
$('.data-source-icon', elem).style.backgroundImage = `url('/ds-image/${item.data_source_name}')`;
$('.data-source-icon', elem).title = item.data_source_title;
$('.data-source-icon', elem).dataset.bsToggle = "tooltip";
$('.view-item-link', elem).href = `/items/${item.repo_id}/${item.id}`;
$('.view-entity-link', elem).href = `/entities/${item.repo_id}/${item.entity.id}`;
if (options?.withToRelations && item.related) {
const toContainer = document.createElement('div');
toContainer.classList.add('d-flex');
toContainer.innerHTML = `
`;
toContainer.append(recipients(item));
$('.message-sender', elem).parentNode.insertAdjacentElement('afterend', toContainer);
}
if (item.data_text) {
$('.message-content', elem).innerText = item.data_text;
} else {
let mediaElem = itemContentElement(item, { thumbnail: true, noLazyLoading: options?.noLazyLoading });
mediaElem.classList.add("rounded");
$('.message-content', elem).appendChild(mediaElem);
}
if (item.related) {
const reactions = {};
for (const rel of item.related) {
if (rel.label == 'attachment' && rel.to_item) {
$('.attachments', elem).classList.remove('d-none');
if (rel.to_item.data_type?.startsWith('image/')) {
let imgTag = document.createElement('span');
imgTag.classList.add("avatar", "avatar-xl", "m-1");
imgTag.style.backgroundImage = `url('${itemImgSrc(rel.to_item, true)}')`;
$('.attachments', elem).appendChild(imgTag);
} else if (rel.to_item?.data_type?.startsWith('video/')) {
let videoTag = document.createElement('video');
videoTag.src = `/repo/${item.repo_id}/${rel.to_item.data_file}`;
videoTag.setAttribute('type', rel.to_item.data_type);
videoTag.setAttribute('controls', '');
$('.attachments', elem).appendChild(videoTag);
}
} else if (rel.label == 'reacted' && rel.from_entity) {
if (reactions[rel.value] === undefined) {
reactions[rel.value] = [];
}
reactions[rel.value].push(rel);
}
}
// TODO: put these somewhere more permanent
const reactionLabels = {
"\u2764\uFE0F": "Love", // ❤️ but red
"👍": "Like",
"👎": "Dislike",
"😂": "Laugh",
"\u203C\uFE0F": "Emphasis", // ‼️ but red
"❓": "Question",
};
// render any reactions we accumulated
if (Object.keys(reactions).length) {
$('.message-reactions', elem).classList.remove('d-none');
for (const [reaction, rels] of Object.entries(reactions)) {
var reactElem = document.createElement('div');
reactElem.classList.add('message-reaction');
reactElem.dataset.bsToggle = "tooltip";
var emojiElem = document.createElement('span');
emojiElem.classList.add('emoji');
emojiElem.textContent = reaction;
reactElem.append(emojiElem);
if (rels.length > 1) {
reactElem.innerHTML += ` ${rels.length}`;
}
reactElem.title = `${reactionLabels[reaction] || reaction} (${rels.length}): `;
for (const rel of rels) {
reactElem.title += `${entityDisplayNameAndAttr(rel.from_entity).name}, `;
}
reactElem.title = reactElem.title.slice(0, -2); // trim off trailing comma and space
$('.message-reactions', elem).appendChild(reactElem);
}
}
}
return elem;
}
function timelineGroups(items, options) {
if (!items) return [];
const groups = [];
let lastLocGroupIdx;
for (const item of items) {
if (!item) continue;
// TODO: only do social as conversation if it has a sent relation...
item._category = item.classification || "unknown"; // in case item is not classified, still have a category, otherwise no group is made and an error occurs below (see #36)
if (item.classification == 'message'
|| item.classification == 'email'
|| item.classification == 'social') {
item._category = "conversation";
}
if (!options?.noMap && item.latitude && item.longitude && item._category == 'location') {
if (!lastLocGroupIdx) {
// it makes more sense to see a map before the items at those locations, so show
// locations before the current group, i.e. 1 from the end (-2 because 0-based indexing)
groups.splice(groups.length-2, 0, []);
// TODO: make sure this group has a category of location...
lastLocGroupIdx = Math.max(groups.length-2, 0);
}
groups[lastLocGroupIdx].push(item);
}
if (groups[groups.length-1]?.[0]?._category != item._category) {
// current item is in different category as previous item, so create new group
groups.push([]);
}
if (item._category == "location") {
// put locations in the most recent location group
lastLocGroupIdx = groups.length-1;
}
groups[groups.length-1].push(item);
}
// split messages into groups by conversation (participants)
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.length > 0 && group[0]._category != "conversation") {
continue;
}
const convos = {};
for (const item of group) {
const participants = [ item.entity?.id ];
if (item.related) {
for (const rel of item.related) {
if ((rel.label == 'sent' || rel.label == 'cc') && rel.to_entity) {
participants.push(rel.to_entity.id);
}
}
}
participants.sort();
const convoKey = participants.join(',');
if (!convos[convoKey]) {
convos[convoKey] = [];
}
convos[convoKey].push(item);
}
const convosArray = Object.values(convos);
convosArray.sort((a, b) => a[0].timestamp > b[0].timestamp ? 1 : -1 );
groups.splice(i, 1, ...convosArray);
}
// further divide groups by temporal locality and max size
const newGroups = [];
const maxGroupSizes = {
conversation: 50,
media: 25,
location: 100
};
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
let g = [];
let window = [];
const windowSize = 3;
let windowAvgTimeDelta = 0, windowVariance = 0;
for (let j = 0; j < group.length; j++) {
const item = group[j];
if (j == 0) {
g.push(item);
continue;
}
const prevItem = group[j-1];
// create new group if max size has been reached, or if it's been a long time since the last item
if (g.length >= maxGroupSizes[g[0]._category]) {
newGroups.push(g);
g = [];
} else {
const delta = durationBetween(item.timestamp, prevItem.timestamp).as('seconds');
window.push(delta);
if (window.length == 1) {
windowAvgTimeDelta = delta;
}
const oldAvg = windowAvgTimeDelta;
const goingAway = window[0];
const changeOfAvgTimeDelta = (delta - goingAway) / windowSize;
windowAvgTimeDelta += changeOfAvgTimeDelta;
if (window.length > windowSize) {
window = window.slice(-windowSize);
}
windowVariance += (delta - goingAway) * ((delta - windowAvgTimeDelta) + (goingAway - oldAvg)) / (windowSize - 1);
const sd = Math.sqrt(windowVariance)
// console.log("DELTA:", delta, "WINDOW:", window, "SD:", sd, "DIFF:", Math.abs(windowAvgTimeDelta - delta), "ITEM:", item);
if (window.length == windowSize && Math.abs(windowAvgTimeDelta - delta) > sd * 1.75) {
// console.log("NEW GROUP:", g)
newGroups.push(g);
g = [];
}
}
g.push(item);
}
if (g.length) {
newGroups.push(g);
}
}
console.log("GROUPS:", newGroups);
return newGroups;
// TODO: I'm not sure this is a good idea, but:
////////////////////////
// display items in ASC order (oldest at top, newest at bottom)
// if ($('.filter .date-sort').value == "DESC") {
// groups.forEach(group => group.reverse());
// }
// console.log("GROUPS:", groups)
// return groups;
}
function renderTimelineGroups(groups, options) {
const timelineEl = cloneTemplate('#tpl-timeline');
groups.forEach((group, i) => {
if (!group.length) return;
const prevGroup = groups[i-1];
const groupDate = new Date(group[0].timestamp);
const prevGroupDate = new Date(prevGroup?.[prevGroup?.length-1]?.timestamp);
let sortVal = $('.date-sort').value;
const firstItem = sortVal == 'ASC' ? group[0] : group[group.length-1];
const lastItem = sortVal == 'ASC' ? group[group.length-1] : group[0];
const tsDisplay = itemTimestampDisplay(firstItem, lastItem);
if (prevGroupDate?.getDate() != groupDate.getDate()
|| prevGroupDate?.getMonth() != groupDate.getMonth()
|| prevGroupDate?.getFullYear() != groupDate.getFullYear()) {
const dateEl = cloneTemplate('#tpl-tl-item-date-card');
$('.list-timeline-date', dateEl).innerText = tsDisplay.weekdayDate;
timelineEl.append(dateEl);
}
const itemEl = cloneTemplate('#tpl-tl-item-card');
$('.list-timeline-time', itemEl).innerText = tsDisplay.time;
const display = itemMiniDisplay(group, options);
if (display.element) {
$('.list-timeline-icon', itemEl).innerHTML = display.icon;
$('.list-timeline-icon', itemEl).classList.add(`bg-${display.iconColor}`);
$('.list-timeline-content-container', itemEl).append(display.element);
$('.list-timeline-date-anchor:last-of-type', timelineEl).append(itemEl);
}
});
return timelineEl;
}
function itemsAsTimeline(items, options) {
const groups = timelineGroups(items, options);
return renderTimelineGroups(groups, options);
}
////////////////////////////////////////////////////
// Item preview modal
////////////////////////////////////////////////////
const PreviewModal = (function() {
// private state
const private = {
reset: function() {
$('#modal-preview-content').replaceChildren();
$('#modal-preview .media-owner-name').replaceChildren();
$('#modal-preview .media-owner-avatar').replaceChildren();
}
};
// public interface
const public = {
// prev, next functions should be set by the pages that use this modal
renderItem: async function(item) {
private.reset();
$('#modal-preview .media-owner-avatar').innerHTML = avatar(true, item.entity, "me-3");
$('#modal-preview .media-owner-name').innerText = entityDisplayNameAndAttr(item.entity).name;
$('#modal-preview .text-secondary').innerText = DateTime.fromISO(item.timestamp, { setZone: true }).toLocaleString(DateTime.DATETIME_MED);
$('#modal-preview .modal-title').innerHTML = ``;
$('#modal-preview .modal-title').appendChild(document.createTextNode(item?.filename || baseFilename(item?.data_file)));
$('#modal-preview .subheader').innerText = `# ${item.id}`;
const mediaElem = itemContentElement(item);
mediaElem.classList.add('rounded');
$('#modal-preview-content').append(mediaElem);
// toggle prev/next buttons
private.prevItem = await this.prev(item);
private.nextItem = await this.next(item);
if (private.prevItem) {
$$('#modal-preview .btn-prev').forEach(elem => elem.classList.remove('disabled'));
} else {
$$('#modal-preview .btn-prev').forEach(elem => elem.classList.add('disabled'));
}
if (private.nextItem) {
$$('#modal-preview .btn-next').forEach(elem => elem.classList.remove('disabled'));
} else {
$$('#modal-preview .btn-next').forEach(elem => elem.classList.add('disabled'));
}
$('#modal-preview .btn-primary').href = `/items/${item.repo_id}/${item.id}`;
// TODO: is this used/needed?
private.currentItem = item;
}
};
// wire up previous/next buttons
on('click', '#modal-preview .btn-prev', async() => {
await public.renderItem(private.prevItem);
});
on('click', '#modal-preview .btn-next', async() => {
await public.renderItem(private.nextItem);
});
// when starting to hide the modal, stop any videos that are playing
on('hide.bs.modal', '#modal-preview', () => {
$('#modal-preview video')?.pause();
});
// when modal is fully hidden, clear it out
on('hidden.bs.modal', '#modal-preview', private.reset);
return public;
})();
function itemPreviews(items) {
// only show a handful of items in the preview
const maxItems = 5;
const more = items.length - maxItems;
items = items.slice(0, maxItems);
const container = cloneTemplate('#tpl-compact-preview');
for (const item of items) {
const contentEl = itemContentElement(item, {
thumbnail: true,
maxLength: 100,
autoplay: true
});
container.append(contentEl);
}
if (more > 0) {
container.innerHTML += `
`;
}
const ts = new TomSelect(element, {
valueField: "id",
maxItems: maxItems,
searchField: [
{
field: "id",
weight: 2
},
{
field: "name",
weight: 1
},
// TomSelect isn't capable of filtering nested elements, so we flatten attributes for it...
{
field: "ts_attribute_0",
weight: 0.5
},
{
field: "ts_attribute_1",
weight: 0.5
},
{
field: "ts_attribute_2",
weight: 0.5
},
{
field: "ts_attribute_3",
weight: 0.5
},
{
field: "ts_attribute_4",
weight: 0.5
},
],
load: function(query, callback) {
const params = {
repo: tlz.openRepos[0].instance_id,
"or_fields": true
};
if (query.startsWith("id:")) {
query = query.slice(3);
params.row_id = [Number(query)];
} else {
params.name = [`%${query}%`];
params.attributes = [
{
"value": `%${query}%`
}
];
}
app.SearchEntities(params).then((results) => {
// I don't think TomSelect is capable of filtering results based on
// complex structures / nested fields... so purely for the benefit of
// TomSelect, flatten attribute values into the top level of each result
for (let i = 0; i < results?.length; i++) {
for (let j = 0; j < results[i].attributes.length; j++) {
results[i][`ts_attribute_${j}`] = results[i].attributes[j].value;
}
}
callback(results);
}).catch((err)=>{
// TODO: handle errors here....
console.error(err);
callback();
});
},
render: {
item: tomSelectRenderItemAndOption,
option: tomSelectRenderItemAndOption
}
});
if (noWrap) {
ts.control.classList.add('single-line');
}
// Clear input after selecting matching option from list
// (I have no idea why this isn't the default behavior)
ts.on('item_add', () => ts.control_input.value = '' );
return ts;
}
////////////////////////////////////////////////////
// Filtering stuff
////////////////////////////////////////////////////
function filterToQueryString() {
var qs = new URLSearchParams(window.location.search);
// date(s)
if ($('.date-input')) {
const pickerDates = $('.date-input').datepicker.selectedDates;
if (pickerDates.length > 0) {
qs.set('start', dateToUnixSec(pickerDates[0]));
} else {
qs.delete('start');
}
if (pickerDates.length == 2) {
qs.set('end', dateToUnixSec(pickerDates[1]));
} else {
qs.delete('end');
}
}
// sort
// TODO: ASC should be default, yeah?
if ($('.date-sort')) {
let sortVal = $('.date-sort').value;
if (sortVal && sortVal != 'DESC') {
qs.set('sort', sortVal);
} else {
qs.delete('sort');
}
}
// entity
if ($('.entity-input.tomselected')) {
const ts = $('.entity-input.tomselected').tomselect;
const val = ts.getValue();
if (Array.isArray(val) && val.length) {
qs.set("entity", val.join(','));
} else {
qs.delete("entity");
}
}
if ($('#selected-entities-only')) {
// whether selected entities are necessary or sufficient;
// so far, this is exclusive to the conversations page
const onlySelected = $('#selected-entities-only').checked;
if (onlySelected) {
qs.set("only_entity", "true");
} else {
qs.delete("only_entity")
}
}
// data sources
if ($('.tl-data-source.tomselected')) {
const ts = $('.filter .tl-data-source.tomselected').tomselect;
const val = ts.getValue();
if (Array.isArray(val) && val.length) {
qs.set("data_source", val.join(','));
} else {
qs.delete("data_source");
}
}
// item types
const checkedItemClasses = $$('.tl-item-class-dropdown input:checked');
const allItemClasses = $$('.tl-item-class-dropdown input')
if (checkedItemClasses.length < allItemClasses.length) {
const itemClasses = [];
for (const elem of checkedItemClasses) {
itemClasses.push(elem.value);
}
qs.set('class', itemClasses);
} else {
qs.delete('class');
}
if ($('#format-images')) {
if (!$('#format-images').checked)
qs.set('images', 0)
else
qs.delete('images');
}
if ($('#format-videos')) {
if (!$('#format-videos').checked)
qs.set('videos', 0)
else
qs.delete('videos');
}
if ($('#include-attachments')) {
if (!$('#include-attachments').checked)
qs.set('attachments', 0)
else
qs.delete('attachments');
}
// semantic search
if ($('.semantic-text-search')) {
if ($('.semantic-text-search').value)
qs.set('semantic_text', $('.semantic-text-search').value);
else
qs.delete('semantic_text');
}
// text search for conversations
if ($('#message-substring')) {
if ($('#message-substring').value)
qs.set('text', $('#message-substring').value);
else
qs.delete('text');
}
return qs;
}
async function queryStringToFilter() {
const qs = new URLSearchParams(window.location.search);
// display page numbers
$$('.page-number').forEach((el) => el.innerText = `Page ${currentPageNum()}`);
// date filter
// TODO: persist date to session storage, so that if there's no date in qs, we can reuse the last date settings the user picked
if ($('.date-input')) {
const start = qs.get('start');
const end = qs.get('end');
if (start && end) {
$('.date-input').datepicker.update({range: true});
}
if (start || end) {
const dates = [];
if (start) dates.push(unixSecToDate(start));
if (end) dates.push(unixSecToDate(end));
$('.date-input').datepicker.selectDate(dates);
}
}
if ($('.date-sort') && qs.get('sort')) {
$('.date-sort').value = qs.get('sort');
}
// entity
if ($('.entity-input') && qs.get("entity")) {
const initialEntity = Number(qs.get("entity"))
const entities = await app.SearchEntities({
repo: tlz.openRepos[0].instance_id,
row_id: [initialEntity]
});
if (entities?.length) {
$('.entity-input').tomselect.addOption(entities[0]);
$('.entity-input').tomselect.addItem(entities[0].id, true); // true = don't fire event (the updated filter gets submitted later)
}
}
if ($('#selected-entities-only') && qs.get("only_entity")) {
// whether selected entities are necessary or sufficient;
// so far, this is exclusive to the conversations page
$('#selected-entities-only').checked = qs.get("only_entity") == "true";
}
// data sources
if ($('.tl-data-source') && qs.get("data_source")) {
const qsDS = qs.get("data_source");
const tsControl = $('.tl-data-source').tomselect;
if ($('.tl-data-source.tomselected')) {
tsControl.setValue(qsDS.split(','), true); // true = don't fire event (the updated filter gets submitted later)
} else {
tsControl.on('initialize', () => {
tsControl.setValue(qsDS.split(','), true);
});
};
}
// classes (item types)
const itemClasses = qs.get("class");
if (!itemClasses) {
// check all by default
for (var elem of $$('.tl-item-class-dropdown input[type=checkbox]')) {
elem.checked = true;
}
} else {
// check only specified ones
for (const cl of itemClasses.split(",")) {
$(`.tl-item-class-dropdown input[type=checkbox][value="${cl}"]`).checked = true;
}
}
if ($('#format-images'))
$('#format-images').checked = qs.get('images') != "0";
if ($('#format-videos'))
$('#format-videos').checked = qs.get('videos') != "0";
if ($('#include-attachments'))
$('#include-attachments').checked = qs.get('attachments') != "0";
// semantic search
if ($('.semantic-text-search')) {
$('.semantic-text-search').value = qs.get('semantic_text');
}
// text search for conversations
if ($('#message-substring')) {
$('#message-substring').value = qs.get('text') || '';
}
}
// commonFilterSearchParams sets the filter inputs based on the inputted search parameters.
function commonFilterSearchParams(params) {
params.repo = params.repo || tlz.openRepos[0].instance_id;
params.sort = params.sort || $('.filter .date-sort')?.value;
// timestamp
// TODO: do we want to worry about not overwriting existing values passed in? caller could always set those fields afterward and overwrite us instead
// if ($('.filter .date-input') && (!params.timestamp || (!params.start_timestamp && !params.end_timestamp))) {
if ($('.filter .date-input')) {
const dp = $('.filter .date-input').datepicker;
const pickerDates = dp.selectedDates;
if (params.sort == "NEAR") {
// "NEAR" is not actually a real sort direction; just a hint to us
delete params.sort;
if (pickerDates.length == 1) {
if (!dp.timepicker) {
// choose noon (middle of the day), otherwise it's weird to see
// results a day earlier that may be closer to 00:00 on the
// desired day than items on the actual desired day
pickerDates[0].setHours(12, 0, 0);
}
params.timestamp = pickerDates[0];
}
} else {
// Note: It seems that rangeDateTo can have a value even if the user hasn't selected a "to" date explicitly
const rangeEnabled = dp.rangeDateFrom || dp.rangeDateTo;
if (rangeEnabled) {
if (pickerDates.length == 1) {
if (params.sort == "ASC") {
if (!dp.timepicker) {
pickerDates[0].setHours(0, 0, 0); // make whole start day inclusive
}
params.start_timestamp = pickerDates[0];
delete params.end_timestamp;
} else if (params.sort == "DESC") {
if (!dp.timepicker) {
pickerDates[0].setHours(23, 59, 59); // make whole end day inclusive
}
params.end_timestamp = pickerDates[0];
delete params.start_timestamp;
}
} else if (pickerDates.length == 2) {
if (!dp.timepicker) {
// make both days inclusive
pickerDates[0].setHours(0, 0, 0);
pickerDates[1].setHours(23, 59, 59);
}
params.start_timestamp = pickerDates[0];
params.end_timestamp = pickerDates[1];
}
} else if (dp.selectedDates.length == 1) {
// copy dates so we can change them (if necessary) without stepping on each other
// TODO: what if the timepicker IS enabled? we'd have to change the sort to NEAR; a range doesn't make sense
const start = new Date(pickerDates[0]),
end = new Date(pickerDates[0]);
if (!dp.timepicker) {
// make selected date inclusive
start.setHours(0, 0, 0);
end.setHours(23, 59, 59);
}
params.start_timestamp = start;
params.end_timestamp = end;
}
}
}
// entity
if (!params.entity_id && $('.filter .entity-input.tomselected')) {
const ts = $('.filter .entity-input.tomselected').tomselect;
const val = ts.getValue();
if (Array.isArray(val) && val.length) {
params.entity_id = val.map(Number);
} else if (val?.length > 0) {
params.entity_id = [Number(val)];
}
}
// data sources
if (!params.data_source && $('.filter .tl-data-source.tomselected')) {
const ts = $('.filter .tl-data-source.tomselected').tomselect;
const val = ts.getValue();
if (Array.isArray(val) && val.length) {
params.data_source = val;
} else if (val?.length > 0) {
params.data_source = [val];
}
}
// item classifications
const checkedItemClasses = $$('.filter .tl-item-class-dropdown input:checked');
const allItemClasses = $$('.filter .tl-item-class-dropdown input')
if (!params.classification && checkedItemClasses.length && checkedItemClasses.length < allItemClasses.length) {
params.classification = [];
for (const elem of checkedItemClasses) {
params.classification.push(elem.value);
}
}
// semantic text search
if (!params.semantic_text && $('.semantic-text-search')) {
params.semantic_text = $('.semantic-text-search').value;
}
}
// TODO: UNUSED.
// Converts an ArrayBuffer directly to base64, without any intermediate 'convert to string then
// use window.btoa' step. According to my tests, this appears to be a faster approach:
// http://jsperf.com/encoding-xhr-image-data/5
// UPDATED BENCHMARKS (Feb 2022): https://jsben.ch/wnaZC
/*
MIT LICENSE
Copyright 2011 Jon Leighton
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
function base64ArrayBuffer(arrayBuffer) {
var base64 = ''
var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
var bytes = new Uint8Array(arrayBuffer)
var byteLength = bytes.byteLength
var byteRemainder = byteLength % 3
var mainLength = byteLength - byteRemainder
var a, b, c, d
var chunk
// Main loop deals with bytes in chunks of 3
for (var i = 0; i < mainLength; i = i + 3) {
// Combine the three bytes into a single integer
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
// Use bitmasks to extract 6-bit segments from the triplet
a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18
b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12
c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6
d = chunk & 63 // 63 = 2^6 - 1
// Convert the raw binary segments to the appropriate ASCII encoding
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d]
}
// Deal with the remaining bytes and padding
if (byteRemainder == 1) {
chunk = bytes[mainLength]
a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2
// Set the 4 least significant bits to zero
b = (chunk & 3) << 4 // 3 = 2^2 - 1
base64 += encodings[a] + encodings[b] + '=='
} else if (byteRemainder == 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]
a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10
b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4
// Set the 2 least significant bits to zero
c = (chunk & 15) << 2 // 15 = 2^4 - 1
base64 += encodings[a] + encodings[b] + encodings[c] + '='
}
return base64
}
// libheif utility: draws HEIF onto a canvas.
// Inspired by https://github.com/strukturag/libheif especially
// the gh-pages branch: https://strukturag.github.io/libheif/
function drawHEIF(heicImage, canvasEl) {
const ctx = canvasEl.getContext('2d');
var w = heicImage.get_width();
var h = heicImage.get_height();
canvasEl.width = w;
canvasEl.height = h;
let imageData = ctx.createImageData(w, h);
let pendingImageData;
heicImage.display(imageData, function(displayImageData) {
if (!displayImageData) {
console.error("HEIC processing error; no image data");
return;
}
if (window.requestAnimationFrame) {
pendingImageData = displayImageData;
window.requestAnimationFrame(function() {
if (pendingImageData) {
ctx.putImageData(pendingImageData, 0, 0);
pendingImageData = null;
}
}.bind(this));
} else {
ctx.putImageData(displayImageData, 0, 0);
}
}.bind(this));
}