Compare commits

..

7 commits
v31 ... main

Author SHA1 Message Date
Magnus Åhall
a2c441bef2 Bumped to v34 2026-08-16 14:38:08 +02:00
Magnus Åhall
9a74c3e574 Better normalization of node names for navigation 2026-08-16 14:38:00 +02:00
Magnus Åhall
e33ae8a247 Bumped to v33, implemented fast keyboard navigator 2026-08-16 12:21:23 +02:00
Magnus Åhall
4c59ef4cda Bumped to v32 2026-07-18 10:26:14 +02:00
Magnus Åhall
393ee9df24 Clear all storage implemented 2026-07-18 10:25:43 +02:00
Magnus Åhall
af11eb9010 Safeguarding against empty default profile 2026-07-18 09:23:00 +02:00
Magnus Åhall
da8256f002 Reverted SYNC_PAGINATION 2026-07-18 09:08:32 +02:00
8 changed files with 277 additions and 17 deletions

View file

@ -25,9 +25,9 @@ import (
"time" "time"
) )
const VERSION = "v31" const VERSION = "v34"
const CONTEXT_USER = 1 const CONTEXT_USER = 1
const SYNC_PAGINATION = 1 const SYNC_PAGINATION = 200
var ( var (
FlagGenerate bool FlagGenerate bool

View file

@ -63,7 +63,7 @@ export class App {
classList.add('page-' + page) classList.add('page-' + page)
}) })
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{ _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', () => {
this.preferences = this.getPreferences() this.preferences = this.getPreferences()
}) })
@ -93,9 +93,19 @@ export class App {
// Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving.
// Thus, the exception is acceptable to consequent use of alt+shift. // Thus, the exception is acceptable to consequent use of alt+shift.
const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey const CTRL = !event.shiftKey && event.ctrlKey && !event.altKey
const SHIFT_CTRL = event.shiftKey && event.ctrlKey && !event.altKey
const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey
const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey
// Space is better handled with event.code
if (event.code.toUpperCase() == 'SPACE' && SHIFT_CTRL) {
event.preventDefault()
event.stopPropagation()
document.querySelector('n2-nodenavigator').open()
return
}
switch (event.key.toUpperCase()) { switch (event.key.toUpperCase()) {
case 'F2': case 'F2':
this.nodeUI.renameNode() this.nodeUI.renameNode()
@ -133,6 +143,11 @@ export class App {
this.nodeUI.saveNode() this.nodeUI.saveNode()
break break
case ' ':
if (!CTRL) { handled = false; break }
console.log('ctrl+space')
break
default: default:
handled = false handled = false
} }
@ -221,7 +236,7 @@ export class App {
const devPrefSet = localStorage.getItem('device_preference_set') || 'default' const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
const userData = localStorage.getItem('user') || '{"default": {}}' const userData = localStorage.getItem('user') || '{"default": {}}'
const user = JSON.parse(userData) const user = JSON.parse(userData)
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet]) return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet] || {})
}// }}} }// }}}
} }
@ -403,8 +418,175 @@ class N2DragIcon extends CustomHTMLElement {
}// }}} }// }}}
} }
class N2NodeNavigator extends CustomHTMLElement {
static {
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
dialog {
position: fixed;
top: 32px;
left: 32px;
margin-top: 0;
margin-bottom: auto;
margin-left: 0;
margin-right: auto;
}
.field-search {
width: 100%;
&.no-match {
color: #a00;
}
}
.el-path, .el-children {
margin-top: 16px;
}
.el-path {
display: flex;
flex-flow: row nowrap;
gap: 8px;
.separator {
color: var(--color1);
font-weight: bold;
}
}
}
</style>
<dialog data-el="dlg">
<input data-field="search" value="">
<div data-el="path">Path:</div>
<div data-el="children"></div>
</dialog>
`
}
constructor() {// {{{
super(true)
this.rootNode = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
this.currNode = this.rootNode
this.fieldSearch.addEventListener('input', () => {
this.render()
})
this.fieldSearch.addEventListener('keydown', event => {
if (event.key == 'Enter' && this.currNode !== null && this.lastSearchTermResultedInNode) {
globalThis._app.goToNode(this.currNode.UUID, false, false)
this.close()
}
})
}// }}}
async open() {// {{{
if (this.elDlg.open)
return
this.currNode = null
this.fieldSearch.value = ''
this.render()
this.elDlg.showModal()
}// }}}
close() {// {{{
this.elDlg.close()
}// }}}
async render() {// {{{
let currNode = this.rootNode
this.lastSearchTermResultedInNode = false
const searchPath = this.getSearchPath()
const path = []
searchLoop:
for (const search of searchPath) {
// string.split returns an empty string if last character is a space.
// We don't want to make a difference if that's the case.
if (search === '')
continue
// fetched nodes hasn't fetched children automatically.
if (!currNode.hasFetchedChildren())
await currNode.fetchChildren()
// Normalized names of current node's children are matched against
// the current search term and compiled into a list.
// The algorithm stops when there are none or more than one.
const matches = []
for (const childNode of currNode.Children) {
const childName = this.normalizeName(childNode.get('Name'))
if (childName.match(search))
matches.push(childNode)
}
switch (matches.length) {
case 0:
this.fieldSearch.classList.add('no-match')
this.lastSearchTermResultedInNode = false
break searchLoop
case 1:
this.fieldSearch.classList.remove('no-match')
currNode = matches[0]
const separator = document.createElement('div')
separator.classList.add('separator')
separator.innerHTML = `&gt;`
const pathEl = document.createElement('div')
pathEl.innerText = currNode.get('Name')
path.push(separator, pathEl)
this.lastSearchTermResultedInNode = true
continue
default:
// More than one match.
this.lastSearchTermResultedInNode = false
this.fieldSearch.classList.remove('no-match')
break searchLoop
}
}
// The currNode can be the last of the search and not having had it children fetched previously.
if (!currNode.hasFetchedChildren())
await currNode.fetchChildren()
this.elPath.replaceChildren(...path)
// Children are rendered for the current node to be found by the search path.
const search = this.lastSearchTermResultedInNode ? '' : searchPath.slice(-1)
const childNodes = []
for (const childNode of currNode.Children) {
const childName = this.normalizeName(childNode.get('Name'))
if (!childName.match(search))
continue
const childEl = document.createElement('div')
childEl.classList.add('child-node')
childEl.innerText = `${this.normalizeName(childNode.get('Name'))}`
childNodes.push(childEl)
}
this.currNode = currNode
globalThis.navNode = currNode.get('Name') + `, ${this.lastSearchTermResultedInNode}`
this.elChildren.replaceChildren(...childNodes)
}// }}}
normalizeName(name) {// {{{
return name.toLowerCase().replaceAll(/\s+/g, '_')
}// }}}
getSearchPath() {// {{{
return this.fieldSearch.value.toLowerCase().split(/\s+/)
}// }}}
}
customElements.define('n2-crumbs', N2Crumbs) customElements.define('n2-crumbs', N2Crumbs)
customElements.define('n2-crumb', N2Crumb) customElements.define('n2-crumb', N2Crumb)
customElements.define('n2-nodenavigator', N2NodeNavigator)
customElements.define('n2-dragicon', N2DragIcon) customElements.define('n2-dragicon', N2DragIcon)
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -20,6 +20,7 @@ export class CustomHTMLElement extends HTMLElement {
const fieldName = this.toElementName('field', field) const fieldName = this.toElementName('field', field)
this[fieldName] = el this[fieldName] = el
this._fields.set(this.toElementName('', field), el) this._fields.set(this.toElementName('', field), el)
el.classList.add('field-' + el.dataset.field)
} }
const name = el.dataset.el const name = el.dataset.el

View file

@ -328,6 +328,52 @@ export class NodeStore {
this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1) this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
}// }}} }// }}}
deleteAll() {// {{{
this.db.close()
const req = indexedDB.deleteDatabase('notes')
req.onsuccess = async () => {
try {
console.log('Database deleted successfully')
// Deleting the storage will log out the user.
await this.deleteStorage()
await this.deleteCaches()
await this.deleteServiceWorkers()
alert('Database deleted, will now log out.')
location.href = '/login'
} catch (e) {
console.error(e)
alert(e.message)
}
}
req.onerror = (event) => {
console.error('Error deleting database:', event.target.errorCode)
}
req.onblocked = () => {
console.warn('Deletion blocked. Close all other tabs/connections to this DB.')
}
}// }}}
async deleteStorage() {// {{{
// Clearing local- and session-storage in case
// something of value to evil people gets stored there.
localStorage.clear()
sessionStorage.clear()
console.log('Local- and session-storage cleared.')
}// }}}
async deleteCaches() {// {{{
const keys = await caches.keys()
return Promise.all(keys.map(key => caches.delete(key)))
}// }}}
async deleteServiceWorkers() {// {{{
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(r => r.unregister()))
}// }}}
node(uuid, dataIfUndefined, newLevel) {//{{{ node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid] let n = this.nodes[uuid]
if (n === undefined && dataIfUndefined !== undefined) if (n === undefined && dataIfUndefined !== undefined)

View file

@ -238,6 +238,7 @@ export class N2PreferenceSet extends CustomHTMLElement {
super(true) super(true)
this.name = name this.name = name
this.data = data this.data = data
this.validateDefaultProfile()
this.render() this.render()
// Enable the save button when settings are modified. // Enable the save button when settings are modified.
@ -248,6 +249,16 @@ export class N2PreferenceSet extends CustomHTMLElement {
this.elName.addEventListener('click', () => this.updateName()) this.elName.addEventListener('click', () => this.updateName())
this.elDelete.addEventListener('click', () => this.deleteSet()) this.elDelete.addEventListener('click', () => this.deleteSet())
}// }}} }// }}}
validateDefaultProfile() {// {{{
if (!this.data.hasOwnProperty('default'))
this.data.default = {}
if (!this.data.default.hasOwnProperty('DownloadFiles'))
this.data.default.DownloadFiles = false
if (!this.data.default.hasOwnProperty('DownloadImages'))
this.data.default.DownloadImages = false
}// }}}
updateName() {// {{{ updateName() {// {{{
if (this.name == 'default') { if (this.name == 'default') {
alert('Can not change name of the default profile.') alert('Can not change name of the default profile.')

View file

@ -2,7 +2,7 @@ import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
import { API } from 'api' import { API } from 'api'
export class N2PageStorage extends CustomHTMLElement { export class N2PageStorage extends CustomHTMLElement {
static { static {// {{{
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<style> <style>
@ -25,20 +25,24 @@ export class N2PageStorage extends CustomHTMLElement {
margin-top: 24px; margin-top: 24px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.el-btn-clear-all {
margin-top: 16px;
}
</style> </style>
<h1>Local storage</h1> <h1>Local storage</h1>
<h2>Nodes</h2> <h2>Notes</h2>
<div class="table"> <div class="table">
<div>Local nodes</div> <div>Local notes</div>
<div data-el="count-nodes"></div> <div data-el="count-nodes"></div>
<div>Queued to sync</div> <div>History notes</div>
<div data-el="count-queued-nodes"></div>
<div>History nodes</div>
<div data-el="count-history-nodes"></div> <div data-el="count-history-nodes"></div>
<div>Notes to send</div>
<div data-el="count-queued-nodes"></div>
</div> </div>
<h2>Files</h2> <h2>Files</h2>
@ -52,17 +56,24 @@ export class N2PageStorage extends CustomHTMLElement {
<div>Files to send</div> <div>Files to send</div>
<div data-el="count-queued-files"></div> <div data-el="count-queued-files"></div>
</div> </div>
<h2>Clear storage</h2>
<div>Remove all data from this device (not server) and logout the session.</div>
<div>This ensures that no notes or files is left on the browser.</div>
<button data-el="btn-clear-all">Clear and logout</button>
` `
} }// }}}
constructor() { constructor() {// {{{
super(true) super(true)
.elBtnClearAll.addEventListener('click', ()=>this.clearAll())
window._mbus.subscribe('SHOW_PAGE', event => { window._mbus.subscribe('SHOW_PAGE', event => {
if (event.detail.data?.page == 'storage') if (event.detail.data?.page == 'storage')
this.render() this.render()
}) })
} }// }}}
async render() { async render() {// {{{
API.query('GET', '/file/count') API.query('GET', '/file/count')
.then(res => { .then(res => {
this.elCountFilesOnServer.innerText = res.Count this.elCountFilesOnServer.innerText = res.Count
@ -80,6 +91,12 @@ export class N2PageStorage extends CustomHTMLElement {
this.elCountQueuedFiles.innerText = await globalThis.nodeStore.filesMetadata.countToUpload() this.elCountQueuedFiles.innerText = await globalThis.nodeStore.filesMetadata.countToUpload()
this.elCountFilesOnDevice.innerText = await globalThis.nodeStore.filesMetadata.count() this.elCountFilesOnDevice.innerText = await globalThis.nodeStore.filesMetadata.count()
} }// }}}
clearAll() {// {{{
if (!confirm('Do you want to remove ALL data from this device and logout?'))
return
globalThis.nodeStore.deleteAll()
}// }}}
} }
customElements.define('n2-pagestorage', N2PageStorage) customElements.define('n2-pagestorage', N2PageStorage)

View file

@ -20,7 +20,8 @@
<script> <script>
globalThis._VERSION = "{{ .VERSION }}" globalThis._VERSION = "{{ .VERSION }}"
if (navigator.serviceWorker) const token = localStorage.getItem('token')
if (token && navigator.serviceWorker)
navigator.serviceWorker.register('/service_worker.js', { type: 'module' }) navigator.serviceWorker.register('/service_worker.js', { type: 'module' })
</script> </script>
<script type="module" defer> <script type="module" defer>

View file

@ -39,6 +39,8 @@
</div> </div>
<n2-syncprogress></n2-syncprogress> <n2-syncprogress></n2-syncprogress>
<n2-nodenavigator></n2-nodenavigator>
</div> </div>
<script type="module"> <script type="module">