${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`
`
}//}}}
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`