import { MessageBus } from '@mbus'
export class Application {
constructor() {// {{{
window._mbus = new MessageBus()
this.settings = new Settings()
this.devices = new Map()
this.currentDevice = null
this.settingsIcon = null
this.createIcon = null
this.elHeader = null
this.deviceSelector = null
this.searchWidget = null
this.resetDeviceState()
this.renderApplication()
this.retrieveDevices()
.then(() => {
this.deviceSelector.setDevices(this.devices)
this.renderApplication()
})
.catch(err => {
console.error(err)
alert(err)
})
_mbus.subscribe('device_selected', event => this.connectDevice(event.detail.devName))
_mbus.subscribe('search', ()=>this.search())
}// }}}
// resetDeviceState removes the device state such as the records, records tree and search field.
resetDeviceState() {// {{{
if (this.recordsTree) {
this.recordsTree.remove()
this.recordsTree = null
return
}
this.topFolder = new Folder(this, null, 'root')
this.records = null
this.recordsTree = null
if (this.searchWidget)
this.searchWidget.reset()
}// }}}
// retrieveDevices fetches the preconfigured devices from server and populates this.devices.
retrieveDevices() {// {{{
return new Promise((resolve, reject) => {
fetch('/devices')
.then(data => data.json())
.then(json => {
if (!json.OK) {
reject(json.Error)
return
}
this.devices = new Map()
for (const devData of json.Devices) {
const dev = new Device(devData)
this.devices.set(dev.name(), dev)
}
resolve()
})
})
}// }}}
// connectDevice resets the device state, retrieves the records and renders everything necessary.
async connectDevice(devName) {// {{{
this.resetDeviceState()
if (devName == '-') {
this.settings.set('last_device', '')
return
}
try {
const dev = this.devices.get(devName)
if (dev === undefined) {
alert(`Unknown device '${devName}'`)
return
}
this.currentDevice = dev
const records = await this.currentDevice.retrieveRecords()
this.records = this.parseRecords(records)
this.settings.set('last_device', this.currentDevice.name())
this.createFolders()
this.renderDevice()
} catch (err) {
console.error(err)
alert(err)
}
}// }}}
// filteredRecords takes the current search value and only returns records matching it.
filteredRecords() {// {{{
const searchFor = this.searchWidget.value()
if (searchFor === '')
return this.records
const records = this.records.filter(r => {
return (r.name().includes(searchFor))
})
return records
}// }}}
parseRecords(recordsData) {// {{{
const records = recordsData.map(d => new Record(d))
return records
}// }}}
// cleanFolders removes all records from all folders.
// createFolders can then put moved records in the correct
// (or newly created) folders again when record names are updated.
cleanFolders(folder) {// {{{
if (folder === undefined)
folder = this.topFolder
folder.records = []
folder.subfolders.forEach((folder, _label) => {
this.cleanFolders(folder)
})
}// }}}
deleteRecord(id) {// {{{
const i = this.records.findIndex(rec => rec.id() == id)
this.records.splice(i, 1)
}// }}}
sortFolders(a, b) {// {{{
const aLabels = a.labels().reverse()
const bLabels = b.labels().reverse()
for (let i = 0; i < aLabels.length && i < bLabels.length; i++) {
if (aLabels[i] < bLabels[i])
return -1
if (aLabels[i] > bLabels[i])
return 1
}
if (a.length < b.length) return 1
if (a.length > b.length) return -1
return 0
}// }}}
sortRecords(a, b) {// {{{
const aLabels = a.labels().reverse()
const bLabels = b.labels().reverse()
for (let i = 0; i < aLabels.length && i < bLabels.length; i++) {
if (aLabels[i] < bLabels[i])
return -1
if (aLabels[i] > bLabels[i])
return 1
}
return 0
}// }}}
// renderApplication creates and updates application-level elements (not device level elements).
renderApplication() {// {{{
if (this.elHeader === null) {
this.elHeader = document.createElement('div')
this.elHeader.id = 'application-header'
this.deviceSelector = new DeviceSelectWidget(this.devices)
this.searchWidget = new SearchWidget()
this.createIcon = document.createElement('img')
this.createIcon.id = 'create-icon'
this.createIcon.src = `/images/${_VERSION}/icon_create.svg`
this.createIcon.addEventListener('click', () => new RecordDialog(new Record()).show())
this.settingsIcon = document.createElement('img')
this.settingsIcon.id = 'settings-icon'
this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg`
this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show())
this.elHeader.appendChild(this.deviceSelector.render())
this.elHeader.appendChild(this.searchWidget.render())
this.elHeader.appendChild(this.createIcon)
this.elHeader.appendChild(this.settingsIcon)
document.getElementById('app').appendChild(this.elHeader)
document.body.addEventListener('keydown', event => this.handlerKeys(event))
return
}
this.deviceSelector.render()
const lastDevice = this.settings.get('last_device')
if (lastDevice !== '')
this.deviceSelector.selectDevice(lastDevice)
}// }}}
// renderDevice creates the device specific elements and also updates them.
renderDevice() {// {{{
// The recordstree is deleted when making a search and is therefore
// not created along with icons and search above.
if (this.recordsTree === null) {
this.recordsTree = document.createElement('div')
this.recordsTree.id = 'records-tree'
document.getElementById('app').appendChild(this.recordsTree)
}
// Top root folder doesn't have to be shown.
const folders = Array.from(this.topFolder.subfolders.values())
folders.sort(this.sortFolders)
for (const folder of folders)
this.recordsTree.append(folder.render())
this.removeEmptyFolders()
// Subscribe to settings update since the elements they will change
// exists now.
_mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail))
this.setBoxedFolders(this.settings.get('boxed_folders'))
}// }}}
// createFolders goes through the filtered records and create new folders based on the record labels.
// It also populates the child records.
createFolders() {// {{{
const records = this.filteredRecords()
records.sort(this.sortRecords)
for (const rec of records) {
// It felt wrong when records for the base domain (e.g. google.com) was put in the top level domain (.com).
// While technically correct, the first label (com) is grouped together with the second label (google).
// Labels are counted reversely since the top domain is most significant.
//
// The `if` here is the exception for records that only has the two first labels. It would otherwise just
// be an empty array of labels and thus discarded.
let labels = rec.labels().reverse().slice(0, -1)
if (rec.labels().length == 1)
labels = rec.labels()
// Start each record from the top and iterate through all its labels
// except the first one since that would be the actual record.
let currFolder = this.topFolder
let accFolderLabels = []
for (const i in labels) {
const label = labels[i]
// The accumulated name is used to create each folder as the record progresses.
accFolderLabels.push(label)
const accFolderName = accFolderLabels.map(v => v).reverse().join('.')
// A new folder is created only when it doesn't exist
// to be able to update them when necessary.
let folder = currFolder.subfolders.get(label)
if (folder === undefined) {
folder = new Folder(this, currFolder, accFolderName)
currFolder.subfolders.set(label, folder)
}
currFolder = folder
}
// Add the record to the innermost folder
currFolder.addRecord(rec)
}
}// }}}
// removeEmptyFolders finds the leaf folders and recursively removes empty ones.
// These are usually left from moving records away from them.
removeEmptyFolders(folder) {// {{{
if (folder === undefined)
folder = this.topFolder
folder.subfolders.forEach((folder, _label) => {
this.removeEmptyFolders(folder)
})
// This is a leaf folder in the tree.
// It has to be removed from the parent as well, since that could be up for
// removal as well, all the way up the chain.
if (folder.subfolders.size === 0 && folder.records.length === 0) {
if (folder.parentFolder) {
folder.parentFolder.subfolders.delete(folder.labels()[0])
}
folder.div?.remove()
}
}// }}}
handlerKeys(event) {// {{{
let handled = true
// Every keyboard shortcut for the application wide handler is using Alt+Shift
// for consistency and that it works with a lot of browsers.
if (!event.altKey || !event.shiftKey || event.ctrlKey) {
return
}
switch (event.key.toLowerCase()) {
case 'n':
const existingDialog = document.getElementById('record-dialog')
if (existingDialog === null)
new RecordDialog(new Record()).show()
break
case 'f':
this.searchWidget.searchField.focus()
break
case 'd':
new DeviceDialog(this.devices).render()
break
default:
handled = false
}
if (handled) {
event.stopPropagation()
event.preventDefault()
}
}// }}}
handlerSettingsUpdated({ key, value }) {// {{{
if (key == 'boxed_folders') {
this.setBoxedFolders(value)
}
}// }}}
setBoxedFolders(state) {// {{{
if (state)
document.body.classList.add('boxed-folders')
else
document.body.classList.remove('boxed-folders')
}// }}}
// search sets the search filter and re-renders the records tree.
search() {// {{{
this.recordsTree.remove()
this.topFolder = new Folder(this, null, 'root')
this.recordsTree = null
this.createFolders()
this.topFolder.openFolder(true)
this.renderDevice()
}// }}}
}
class SearchWidget {
constructor() {// {{{
_mbus.subscribe('device_selected', event => {
if (event.detail.devName == '-')
this.searchEl?.classList.remove('show')
else
this.searchEl?.classList.add('show')
})
}// }}}
render() {// {{{
if (this.searchEl)
return
this.searchEl = document.createElement('div')
this.searchEl.id = 'search'
this.searchEl.innerHTML = `