import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' import { Keys, Key } from 'key' import Crypto from 'crypto' import { Checklist, ChecklistGroup } from 'checklist' const html = htm.bind(h) export class NodeUI extends Component { constructor(props) {//{{{ super(props) this.menu = signal(false) this.node = signal(null) this.nodeContent = createRef() this.nodeProperties = createRef() this.keys = signal([]) this.page = signal('node') window.addEventListener('popstate', evt => { if (evt.state && evt.state.hasOwnProperty('nodeID')) this.goToNode(evt.state.nodeID, true) else this.goToNode(0, true) }) window.addEventListener('keydown', evt => this.keyHandler(evt)) }//}}} render() {//{{{ if (this.node.value === null) return let node = this.node.value document.title = `N: ${node.Name}` let crumbs = [ html`
this.goToNode(0)}>Start
` ] crumbs = crumbs.concat(node.Crumbs.slice(0).map(node => html`
this.goToNode(node.ID)}>${node.Name}
` ).reverse()) let children = node.Children.sort((a, b) => { if (a.Name.toLowerCase() > b.Name.toLowerCase()) return 1 if (a.Name.toLowerCase() < b.Name.toLowerCase()) return -1 return 0 }).map(child => html`
this.goToNode(child.ID)}>${child.Name}
`) let modified = '' if (this.props.app.nodeModified.value) modified = 'modified' // Page to display let page = '' switch (this.page.value) { case 'node': if (node.ID == 0) { page = html`
this.page.value = 'schedule-events'}>Schedule events
${children.length > 0 ? html`
${children}
Notes version ${window._VERSION}
` : html``} ` } else { let padlock = '' if (node.CryptoKeyID > 0) padlock = html`` page = html` ${children.length > 0 ? html`
${children}
` : html``}
${node.Name} ${padlock}
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} /> <${Checklist} ui=${this} groups=${node.ChecklistGroups} /> <${NodeFiles} node=${this.node.value} /> ` } break case 'upload': page = html`<${UploadUI} nodeui=${this} />` break case 'node-properties': page = html`<${NodeProperties} ref=${this.nodeProperties} nodeui=${this} />` break case 'keys': page = html`<${Keys} nodeui=${this} />` break case 'profile-settings': page = html`<${ProfileSettings} nodeui=${this} />` break case 'search': page = html`<${Search} nodeui=${this} />` break case 'schedule-events': page = html`<${ScheduleEventList} nodeui=${this} />` break } let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null) let checklist = () => html`
{ evt.stopPropagation(); this.toggleChecklist() }}>
` return html` <${menu} />
this.saveNode()}>
document.getElementById('app').classList.toggle('toggle-tree')} />
Notes
{ evt.stopPropagation(); this.toggleMarkdown() }}>
<${checklist} />
this.createNode(evt)}>
{ evt.stopPropagation(); this.showPage('keys') }}>
${crumbs}
${page} ` }//}}} async componentDidMount() {//{{{ // When rendered and fetching the node, keys could be needed in order to // decrypt the content. await this.retrieveKeys() this.props.app.startNode.retrieve(node => { this.node.value = node // The tree isn't guaranteed to have loaded yet. This is also run from // the tree code, in case the node hasn't loaded. this.props.app.tree.crumbsUpdateNodes(node) }) }//}}} keyHandler(evt) {//{{{ let handled = true // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. // 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. if (!(evt.shiftKey && evt.altKey) && !(evt.key.toUpperCase() == 'S' && evt.ctrlKey)) return switch (evt.key.toUpperCase()) { case 'C': this.showPage('node') break case 'E': this.showPage('keys') break case 'M': this.toggleMarkdown() break case 'N': this.createNode() break case 'P': this.showPage('node-properties') break case 'S': if (this.page.value == 'node') this.saveNode() else if (this.page.value == 'node-properties') this.nodeProperties.current.save() break case 'U': this.showPage('upload') break case 'F': this.showPage('search') break default: handled = false } if (handled) { evt.preventDefault() evt.stopPropagation() } }//}}} showMenu(evt) {//{{{ evt.stopPropagation() this.menu.value = true }//}}} logout() {//{{{ window.localStorage.removeItem('session.UUID') location.href = '/' }//}}} goToNode(nodeID, dontPush) {//{{{ if (this.props.app.nodeModified.value) { if (!confirm("Changes not saved. Do you want to discard changes?")) return } if (!dontPush) history.pushState({ nodeID }, '', `/?node=${nodeID}`) // New node is fetched in order to retrieve content and files. // Such data is unnecessary to transfer for tree/navigational purposes. let node = new Node(this.props.app, nodeID) node.retrieve(node => { this.props.app.nodeModified.value = false this.node.value = node this.showPage('node') // Tree needs to know another node is selected, in order to render any // previously selected node not selected. this.props.app.tree.setSelected(node) // Hide tree toggle, as this would be the next natural action to do manually anyway. // At least in mobile mode. document.getElementById('app').classList.remove('toggle-tree') }) }//}}} createNode(evt) {//{{{ if (evt) evt.stopPropagation() let name = prompt("Name") if (!name) return this.node.value.create(name, nodeID => { console.log('before', this.props.app.startNode) this.props.app.startNode = new Node(this.props.app, nodeID) console.log('after', this.props.app.startNode) this.props.app.tree.retrieve(() => { this.goToNode(nodeID) }) }) }//}}} saveNode() {//{{{ /* let nodeContent = this.nodeContent.current if (this.page.value != 'node' || nodeContent === null) return */ let content = this.node.value.content() this.node.value.setContent(content) this.node.value.save(() => this.props.app.nodeModified.value = false) }//}}} renameNode() {//{{{ let name = prompt("New name") if (!name) return this.node.value.rename(name, () => { this.goToNode(this.node.value.ID) this.menu.value = false }) }//}}} deleteNode() {//{{{ if (!confirm("Do you want to delete this note and all sub-notes?")) return this.node.value.delete(() => { this.goToNode(this.node.value.ParentID) this.menu.value = false }) }//}}} async retrieveKeys() {//{{{ return new Promise((resolve, reject) => { this.props.app.request('/key/retrieve', {}) .then(res => { this.keys.value = res.Keys.map(keyData => new Key(keyData, this.keyCounter)) resolve(this.keys.value) }) .catch(reject) }) }//}}} keyCounter() {//{{{ return window._app.current.request('/key/counter', {}) .then(res => BigInt(res.Counter)) .catch(window._app.current.responseError) }//}}} getKey(id) {//{{{ let keys = this.keys.value for (let i = 0; i < keys.length; i++) if (keys[i].ID == id) return keys[i] return null }//}}} showPage(pg) {//{{{ this.page.value = pg }//}}} showChecklist() {//{{{ return (this.node.value.ChecklistGroups && this.node.value.ChecklistGroups.length > 0) | this.node.value.ShowChecklist.value }//}}} toggleChecklist() {//{{{ this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value }//}}} toggleMarkdown() {//{{{ this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value }//}}} } class NodeContent extends Component { constructor(props) {//{{{ super(props) this.contentDiv = createRef() this.state = { modified: false, } }//}}} render({ node }) {//{{{ let content = '' try { content = node.content() } catch (err) { return html`
${err.message}
` } var element if (node.RenderMarkdown.value) element = html`<${MarkdownContent} key='markdown-content' content=${content} />` else element = html`
` return element }//}}} componentDidMount() {//{{{ this.resize() window.addEventListener('resize', () => this.resize()) }//}}} componentDidUpdate() {//{{{ this.resize() }//}}} contentChanged(evt) {//{{{ window._app.current.nodeModified.value = true const content = evt.target.value this.props.node.setContent(content) this.resize() }//}}} resize() {//{{{ let textarea = document.getElementById('node-content') if (textarea) textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} unlock() {//{{{ let pass = prompt(`Password for "${this.props.model.description}"`) if (!pass) return try { this.props.model.unlock(pass) this.forceUpdate() } catch (err) { alert(err) } }//}}} } class MarkdownContent extends Component { render({ content }) {//{{{ return html`
` }//}}} componentDidMount() {//{{{ const markdown = document.getElementById('markdown') if (markdown) markdown.innerHTML = marked.parse(this.props.content) }//}}} } class NodeFiles extends Component { render({ node }) {//{{{ if (node.Files === null || node.Files.length == 0) return let files = node.Files .sort((a, b) => { if (a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1 if (a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1 return 0 }) .map(file => html`
node.download(file.ID)}>${file.Filename}
${this.formatSize(file.Size)}
` ) return html`
Files
${files}
` }//}}} formatSize(size) {//{{{ if (size < 1048576) { return `${Math.round(size / 1024)} KiB` } else { return `${Math.round(size / 1048576)} MiB` } }//}}} } export class Node { constructor(app, nodeID) {//{{{ this.app = app this.ID = nodeID this.ParentID = 0 this.UserID = 0 this.CryptoKeyID = 0 this.Name = '' this.RenderMarkdown = signal(false) this.Markdown = false this.ShowChecklist = signal(false) this._content = '' this.Children = [] this.Crumbs = [] this.Files = [] this._decrypted = false this._expanded = false // start value for the TreeNode component, this.ChecklistGroups = {} // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} retrieve(callback) {//{{{ this.app.request('/node/retrieve', { ID: this.ID }) .then(res => { this.ParentID = res.Node.ParentID this.UserID = res.Node.UserID this.CryptoKeyID = res.Node.CryptoKeyID this.Name = res.Node.Name this._content = res.Node.Content this.Children = res.Node.Children this.Crumbs = res.Node.Crumbs this.Files = res.Node.Files this.Markdown = res.Node.Markdown this.RenderMarkdown.value = this.Markdown this.initChecklist(res.Node.ChecklistGroups) callback(this) }) .catch(this.app.responseError) }//}}} delete(callback) {//{{{ this.app.request('/node/delete', { NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} create(name, callback) {//{{{ this.app.request('/node/create', { Name: name.trim(), ParentID: this.ID, }) .then(res => { callback(res.Node.ID) }) .catch(this.app.responseError) }//}}} async save(callback) {//{{{ try { await this.#encrypt() let req = { NodeID: this.ID, Content: this._content, CryptoKeyID: this.CryptoKeyID, Markdown: this.Markdown, TimeOffset: -(new Date().getTimezoneOffset()), } this.app.request('/node/update', req) .then(callback) .catch(this.app.responseError) } catch (err) { this.app.responseError(err) } }//}}} rename(name, callback) {//{{{ this.app.request('/node/rename', { Name: name.trim(), NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} download(fileID) {//{{{ let headers = { 'Content-Type': 'application/json', } if (this.app.session.UUID !== '') headers['X-Session-Id'] = this.app.session.UUID let fname = "" fetch("/node/download", { method: 'POST', headers, body: JSON.stringify({ NodeID: this.ID, FileID: fileID, }), }) .then(response => { let match = response.headers.get('content-disposition').match(/filename="([^"]*)"/) fname = match[1] return response.blob() }) .then(blob => { let url = window.URL.createObjectURL(blob) let a = document.createElement('a') a.href = url a.download = fname document.body.appendChild(a) // we need to append the element to the dom -> otherwise it will not work in firefox a.click() a.remove() //afterwards we remove the element again }) }//}}} content() {//{{{ if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() return this._content }//}}} setContent(new_content) {//{{{ this._content = new_content if (this.CryptoKeyID == 0) // Logic behind plaintext not being decrypted is that // only encrypted values can be in a decrypted state. this._decrypted = false else this._decrypted = true }//}}} async setCryptoKey(new_key) {//{{{ return this.#encrypt(true, new_key) }//}}} #decrypt() {//{{{ if (this.CryptoKeyID == 0 || this._decrypted) return let obj_key = this.app.nodeUI.current.getKey(this.CryptoKeyID) if (obj_key === null || obj_key.ID != this.CryptoKeyID) throw ('Invalid key') // Ask user to unlock key first var pass = null while (pass || obj_key.status() == 'locked') { pass = prompt(`Password for "${obj_key.description}"`) if (!pass) throw new Error(`Key "${obj_key.description}" is locked`) try { obj_key.unlock(pass) } catch (err) { alert(err) } pass = null } if (obj_key.status() == 'locked') throw new Error(`Key "${obj_key.description}" is locked`) let crypto = new Crypto(obj_key.key) this._decrypted = true this._content = sjcl.codec.utf8String.fromBits( crypto.decrypt(this._content) ) }//}}} async #encrypt(change_key = false, new_key = null) {//{{{ // Nothing to do if not changing key and already encrypted. if (!change_key && this.CryptoKeyID != 0 && !this._decrypted) return this._content let content = this.content() // Changing key to no encryption or already at no encryption - // set to not decrypted (only encrypted values can be // decrypted) and return plain value. if ((change_key && new_key === null) || (!change_key && this.CryptoKeyID == 0)) { this._decrypted = false this.CryptoKeyID = 0 return content } let key_id = change_key ? new_key.ID : this.CryptoKeyID let obj_key = this.app.nodeUI.current.getKey(key_id) if (obj_key === null || obj_key.ID != key_id) throw ('Invalid key') if (obj_key.status() == 'locked') throw new Error(`Key "${obj_key.description}" is locked`) let crypto = new Crypto(obj_key.key) let content_bits = sjcl.codec.utf8String.toBits(content) let counter = await this.app.nodeUI.current.keyCounter() this.CryptoKeyID = obj_key.ID this._content = crypto.encrypt(content_bits, counter, true) this._decrypted = false return this._content }//}}} initChecklist(checklistData) {//{{{ if (checklistData === undefined || checklistData === null) return this.ChecklistGroups = checklistData.map(groupData => { return new ChecklistGroup(groupData) }) }//}}} } class Menu extends Component { render({ nodeui }) {//{{{ return html`
nodeui.menu.value = false}>
` }//}}} } class UploadUI extends Component { constructor(props) {//{{{ super(props) this.file = createRef() this.filelist = signal([]) this.fileRefs = [] this.progressRefs = [] }//}}} render({ nodeui }) {//{{{ let filelist = this.filelist.value let files = [] for (let i = 0; i < filelist.length; i++) { files.push(html`
${filelist.item(i).name}
`) } return html`
nodeui.showPage('node')}>
this.upload()} multiple />
${files}
` }//}}} componentDidMount() {//{{{ this.file.current.focus() }//}}} upload() {//{{{ let nodeID = this.props.nodeui.node.value.ID this.fileRefs = [] this.progressRefs = [] let input = this.file.current this.filelist.value = input.files for (let i = 0; i < input.files.length; i++) { this.fileRefs.push(createRef()) this.progressRefs.push(createRef()) this.postFile( input.files[i], nodeID, progress => { this.progressRefs[i].current.innerHTML = `${progress}%` }, res => { this.props.nodeui.node.value.Files.push(res.File) this.props.nodeui.forceUpdate() this.fileRefs[i].current.classList.add("done") this.progressRefs[i].current.classList.add("done") this.props.nodeui.showPage('node') }) } }//}}} postFile(file, nodeID, progressCallback, doneCallback) {//{{{ var formdata = new FormData() formdata.append('file', file) formdata.append('NodeID', nodeID) var request = new XMLHttpRequest() request.addEventListener("error", () => { window._app.current.responseError({ upload: "An unknown error occured" }) }) request.addEventListener("loadend", () => { if (request.status != 200) { window._app.current.responseError({ upload: request.statusText }) return } let response = JSON.parse(request.response) if (!response.OK) { window._app.current.responseError({ upload: response.Error }) return } doneCallback(response) }) request.upload.addEventListener('progress', evt => { var fileSize = file.size if (evt.loaded <= fileSize) progressCallback(Math.round(evt.loaded / fileSize * 100)) if (evt.loaded == evt.total) progressCallback(100) }) request.open('post', '/node/upload') request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) //request.timeout = 45000 request.send(formdata) }//}}} } class NodeProperties extends Component { constructor(props) {//{{{ super(props) this.props.nodeui.retrieveKeys() this.selected_key_id = 0 }//}}} render({ nodeui }) {//{{{ let keys = nodeui.keys.value .sort((a, b) => { if (a.description < b.description) return -1 if (a.description > b.description) return 1 return 0 }) .map(key => { this.props.nodeui.keys.value.some(uikey => { if (uikey.ID == nodeui.node.value.ID) { this.selected_key_id = nodeui.node.value.ID return true } }) if (nodeui.node.value.CryptoKeyID == key.ID) this.selected_key_id = key.ID return html`
this.selected_key_id = key.ID} />
` }) return html`

Note properties

These properties are only for this note.
nodeui.node.value.Markdown = evt.target.checked} />

Encryption

this.selected_key_id = 0} />
${keys}
` }//}}} async save() {//{{{ let nodeui = this.props.nodeui let node = nodeui.node.value // Find the actual key object used for encryption let new_key = nodeui.getKey(this.selected_key_id) let current_key = nodeui.getKey(node.CryptoKeyID) if (current_key && current_key.status() == 'locked') { alert("Decryption key is locked and can not be used.") return } if (new_key && new_key.status() == 'locked') { alert("Key is locked and can not be used.") return } await node.setCryptoKey(new_key) if (node.Markdown != node.RenderMarkdown.value) node.RenderMarkdown.value = node.Markdown node.save(() => this.props.nodeui.showPage('node')) }//}}} } class Search extends Component { constructor() {//{{{ super() this.state = { matches: [], results_returned: false, } }//}}} render({ nodeui }, { matches, results_returned }) {//{{{ let match_elements = [ html`

Results

`, ] let matched_nodes = matches.map(node => html`
nodeui.goToNode(node.ID)}> ${node.Name}
`) match_elements.push(html`
${matched_nodes}
`) return html` ` }//}}} componentDidMount() {//{{{ document.getElementById('search-for').focus() }//}}} keyHandler(evt) {//{{{ let handled = true switch (evt.key.toUpperCase()) { case 'ENTER': this.search() break default: handled = false } if (handled) { evt.preventDefault() evt.stopPropagation() } }//}}} search() {//{{{ let Search = document.getElementById('search-for').value window._app.current.request('/node/search', { Search }) .then(res => { this.setState({ matches: res.Nodes, results_returned: true, }) }) .catch(window._app.current.responseError) }//}}} } class ProfileSettings extends Component { render({ nodeui }, { }) {//{{{ return html`

User settings

Password

Current
this.keyHandler(evt)} />
New
this.keyHandler(evt)} />
Repeat
this.keyHandler(evt)} />
` }//}}} componentDidMount() {//{{{ document.getElementById('current-password').focus() }//}}} keyHandler(evt) {//{{{ let handled = true switch (evt.key.toUpperCase()) { case 'ENTER': this.updatePassword() break default: handled = false } if (handled) { evt.preventDefault() evt.stopPropagation() } }//}}} updatePassword() {//{{{ let curr_pass = document.getElementById('current-password').value let pass1 = document.getElementById('new-password1').value let pass2 = document.getElementById('new-password2').value try { if (pass1.length < 4) { throw new Error('Password has to be at least 4 characters long') } if (pass1 != pass2) { throw new Error(`Passwords don't match`) } window._app.current.request('/user/password', { CurrentPassword: curr_pass, NewPassword: pass1, }) .then(res => { if (res.CurrentPasswordOK) alert('Password is changed successfully') else alert('Current password is invalid') }) } catch (err) { alert(err.message) } }//}}} } class ScheduleEventList extends Component { constructor() { super() this.retrieveFutureEvents() } render() { } retrieveFutureEvents() { _app.current.request('/schedule/list') .then(foo=>{ console.log(foo) }) } } // vim: foldmethod=marker