Notes/static/js/node.mjs

1117 lines
30 KiB
JavaScript
Raw Normal View History

2023-06-18 20:13:35 +02:00
import { h, Component, createRef } from 'preact'
2023-06-17 09:11:14 +02:00
import htm from 'htm'
import { signal } from 'preact/signals'
2023-07-01 20:33:26 +02:00
import { Keys, Key } from 'key'
2023-07-12 22:35:38 +02:00
import Crypto from 'crypto'
import { Checklist, ChecklistGroup } from 'checklist'
2023-06-17 09:11:14 +02:00
const html = htm.bind(h)
export class NodeUI extends Component {
2023-07-12 22:35:38 +02:00
constructor(props) {//{{{
super(props)
2023-06-18 22:05:10 +02:00
this.menu = signal(false)
2023-06-17 09:11:14 +02:00
this.node = signal(null)
2023-06-18 20:13:35 +02:00
this.nodeContent = createRef()
this.nodeProperties = createRef()
2023-07-01 20:33:26 +02:00
this.keys = signal([])
this.page = signal('node')
2024-01-09 16:28:40 +01:00
window.addEventListener('popstate', evt => {
if (evt.state && evt.state.hasOwnProperty('nodeID'))
2023-06-17 09:11:14 +02:00
this.goToNode(evt.state.nodeID, true)
else
this.goToNode(0, true)
})
2023-06-27 14:44:36 +02:00
2024-01-09 16:28:40 +01:00
window.addEventListener('keydown', evt => this.keyHandler(evt))
2023-06-17 09:11:14 +02:00
}//}}}
render() {//{{{
2024-01-09 16:28:40 +01:00
if (this.node.value === null)
2023-06-17 09:11:14 +02:00
return
let node = this.node.value
2023-07-18 06:27:50 +02:00
document.title = `N: ${node.Name}`
2023-06-17 09:11:14 +02:00
let crumbs = [
2024-01-09 16:28:40 +01:00
html`<div class="crumb" onclick=${() => this.goToNode(0)}>Start</div>`
2023-06-17 09:11:14 +02:00
]
2024-01-09 16:28:40 +01:00
crumbs = crumbs.concat(node.Crumbs.slice(0).map(node =>
html`<div class="crumb" onclick=${() => this.goToNode(node.ID)}>${node.Name}</div>`
2023-06-17 09:11:14 +02:00
).reverse())
2024-01-09 16:28:40 +01:00
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
2023-06-17 09:11:14 +02:00
return 0
2024-01-09 16:28:40 +01:00
}).map(child => html`
<div class="child-node" onclick=${() => this.goToNode(child.ID)}>${child.Name}</div>
2023-06-17 09:11:14 +02:00
`)
2023-06-18 20:13:35 +02:00
let modified = ''
2024-01-09 16:28:40 +01:00
if (this.props.app.nodeModified.value)
2023-07-01 20:33:26 +02:00
modified = 'modified'
// Page to display
let page = ''
2024-01-09 16:28:40 +01:00
switch (this.page.value) {
2023-07-01 20:33:26 +02:00
case 'node':
2024-01-09 16:28:40 +01:00
if (node.ID == 0) {
page = html`
2024-04-17 18:43:24 +02:00
<div style="cursor: pointer; color: #000; text-align: center;" onclick=${() => this.page.value = 'schedule-events'}>Schedule events</div>
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
`
} else {
2023-07-12 22:35:38 +02:00
let padlock = ''
2024-01-09 16:28:40 +01:00
if (node.CryptoKeyID > 0)
2023-07-12 22:35:38 +02:00
padlock = html`<img src="/images/${window._VERSION}/padlock-black.svg" style="height: 24px;" />`
2023-07-01 20:33:26 +02:00
page = html`
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
2023-07-12 22:35:38 +02:00
<div class="node-name">
${node.Name} ${padlock}
</div>
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
2024-04-17 18:43:24 +02:00
<${NodeEvents} events=${node.ScheduleEvents.value} />
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
2023-07-01 20:33:26 +02:00
<${NodeFiles} node=${this.node.value} />
`
2023-07-12 22:35:38 +02:00
}
2023-07-01 20:33:26 +02:00
break
2023-06-18 20:13:35 +02:00
2023-07-01 20:33:26 +02:00
case 'upload':
page = html`<${UploadUI} nodeui=${this} />`
break
2023-06-21 23:52:21 +02:00
2023-07-01 20:33:26 +02:00
case 'node-properties':
page = html`<${NodeProperties} ref=${this.nodeProperties} nodeui=${this} />`
2023-07-01 20:33:26 +02:00
break
case 'keys':
page = html`<${Keys} nodeui=${this} />`
break
2023-07-19 10:00:36 +02:00
2023-07-20 10:47:49 +02:00
case 'profile-settings':
page = html`<${ProfileSettings} nodeui=${this} />`
break
2023-07-19 10:00:36 +02:00
case 'search':
page = html`<${Search} nodeui=${this} />`
break
2024-04-03 17:37:32 +02:00
case 'schedule-events':
page = html`<${ScheduleEventList} nodeui=${this} />`
break
2023-07-01 20:33:26 +02:00
}
2024-04-03 17:37:32 +02:00
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
let checklist = () =>
html`
<div class="checklist" onclick=${evt => { evt.stopPropagation(); this.toggleChecklist() }}>
<img src="/images/${window._VERSION}/${this.showChecklist() ? 'checklist-on.svg' : 'checklist-off.svg'}" />
</div>`
2023-06-18 22:05:10 +02:00
2023-06-21 23:52:21 +02:00
return html`
<${menu} />
2024-01-09 16:28:40 +01:00
<header class="${modified}" onclick=${() => this.saveNode()}>
<div class="tree"><img src="/images/${window._VERSION}/tree.svg" onclick=${() => document.getElementById('app').classList.toggle('toggle-tree')} /></div>
2023-06-18 20:13:35 +02:00
<div class="name">Notes</div>
<div class="markdown" onclick=${evt => { evt.stopPropagation(); this.toggleMarkdown() }}><img src="/images/${window._VERSION}/${node.RenderMarkdown.value ? 'markdown.svg' : 'markdown-hollow.svg'}" /></div>
<${checklist} />
2024-01-09 16:28:40 +01:00
<div class="search" onclick=${evt => { evt.stopPropagation(); this.showPage('search') }}><img src="/images/${window._VERSION}/search.svg" /></div>
<div class="add" onclick=${evt => this.createNode(evt)}><img src="/images/${window._VERSION}/add.svg" /></div>
<div class="keys" onclick=${evt => { evt.stopPropagation(); this.showPage('keys') }}><img src="/images/${window._VERSION}/padlock.svg" /></div>
<div class="menu" onclick=${evt => this.showMenu(evt)}></div>
2023-06-18 20:13:35 +02:00
</header>
2023-06-18 22:05:10 +02:00
2023-06-27 14:44:36 +02:00
<div id="crumbs">
<div class="crumbs">${crumbs}</crumbs>
</div>
2023-06-18 22:05:10 +02:00
2023-07-01 20:33:26 +02:00
${page}
2023-06-17 09:11:14 +02:00
`
}//}}}
2023-07-12 22:35:38 +02:00
async componentDidMount() {//{{{
// When rendered and fetching the node, keys could be needed in order to
// decrypt the content.
await this.retrieveKeys()
2024-01-09 16:28:40 +01:00
this.props.app.startNode.retrieve(node => {
2023-06-17 09:11:14 +02:00
this.node.value = node
2023-06-27 14:44:36 +02:00
// 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)
2023-06-17 09:11:14 +02:00
})
}//}}}
2023-06-18 22:05:10 +02:00
2023-06-20 07:59:54 +02:00
keyHandler(evt) {//{{{
let handled = true
2023-07-19 10:00:36 +02:00
// 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.
2024-01-09 16:28:40 +01:00
if (!(evt.shiftKey && evt.altKey) && !(evt.key.toUpperCase() == 'S' && evt.ctrlKey))
2023-07-19 10:00:36 +02:00
return
2024-01-09 16:28:40 +01:00
switch (evt.key.toUpperCase()) {
case 'C':
this.showPage('node')
break
2023-07-01 20:33:26 +02:00
case 'E':
2023-07-19 10:00:36 +02:00
this.showPage('keys')
2023-06-20 07:59:54 +02:00
break
2024-01-09 16:28:40 +01:00
case 'M':
this.toggleMarkdown()
break
2023-06-20 07:59:54 +02:00
case 'N':
2023-07-19 10:00:36 +02:00
this.createNode()
2023-06-20 07:59:54 +02:00
break
2023-07-01 20:33:26 +02:00
case 'P':
2023-07-19 10:00:36 +02:00
this.showPage('node-properties')
2023-07-01 20:33:26 +02:00
break
case 'S':
if (this.page.value == 'node')
this.saveNode()
else if (this.page.value == 'node-properties')
this.nodeProperties.current.save()
2023-07-01 20:33:26 +02:00
break
2023-06-21 23:52:21 +02:00
case 'U':
2023-07-19 10:00:36 +02:00
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
2023-06-21 23:52:21 +02:00
2023-06-20 07:59:54 +02:00
default:
handled = false
}
2024-01-09 16:28:40 +01:00
if (handled) {
2023-06-20 07:59:54 +02:00
evt.preventDefault()
evt.stopPropagation()
}
}//}}}
2023-06-20 08:13:32 +02:00
showMenu(evt) {//{{{
evt.stopPropagation()
2023-06-18 22:05:10 +02:00
this.menu.value = true
}//}}}
2023-06-20 08:13:32 +02:00
logout() {//{{{
window.localStorage.removeItem('session.UUID')
location.href = '/'
}//}}}
2023-06-18 22:05:10 +02:00
2023-06-17 09:11:14 +02:00
goToNode(nodeID, dontPush) {//{{{
2024-01-09 16:28:40 +01:00
if (this.props.app.nodeModified.value) {
if (!confirm("Changes not saved. Do you want to discard changes?"))
2023-06-18 20:13:35 +02:00
return
}
2024-01-09 16:28:40 +01:00
if (!dontPush)
2023-06-18 20:13:35 +02:00
history.pushState({ nodeID }, '', `/?node=${nodeID}`)
2023-06-27 14:44:36 +02:00
// New node is fetched in order to retrieve content and files.
// Such data is unnecessary to transfer for tree/navigational purposes.
2023-06-17 09:11:14 +02:00
let node = new Node(this.props.app, nodeID)
2024-01-09 16:28:40 +01:00
node.retrieve(node => {
2023-06-18 20:13:35 +02:00
this.props.app.nodeModified.value = false
2023-06-17 09:11:14 +02:00
this.node.value = node
2023-07-12 22:35:38 +02:00
this.showPage('node')
2023-06-27 14:44:36 +02:00
// Tree needs to know another node is selected, in order to render any
// previously selected node not selected.
this.props.app.tree.setSelected(node)
2023-06-27 15:08:48 +02:00
// Hide tree toggle, as this would be the next natural action to do manually anyway.
// At least in mobile mode.
2023-07-01 20:33:26 +02:00
document.getElementById('app').classList.remove('toggle-tree')
2023-06-17 09:11:14 +02:00
})
}//}}}
2023-06-20 08:13:32 +02:00
createNode(evt) {//{{{
2024-01-09 16:28:40 +01:00
if (evt)
2023-06-21 23:52:21 +02:00
evt.stopPropagation()
2023-06-18 20:13:35 +02:00
let name = prompt("Name")
2024-01-09 16:28:40 +01:00
if (!name)
2023-06-18 20:13:35 +02:00
return
2024-01-09 16:28:40 +01:00
this.node.value.create(name, nodeID => {
2024-01-05 20:00:02 +01:00
console.log('before', this.props.app.startNode)
this.props.app.startNode = new Node(this.props.app, nodeID)
console.log('after', this.props.app.startNode)
2024-01-09 16:28:40 +01:00
this.props.app.tree.retrieve(() => {
2024-01-05 20:00:02 +01:00
this.goToNode(nodeID)
})
})
2023-06-18 20:13:35 +02:00
}//}}}
2023-06-18 22:05:10 +02:00
saveNode() {//{{{
2024-01-09 16:28:40 +01:00
let content = this.node.value.content()
2023-07-14 16:17:37 +02:00
this.node.value.setContent(content)
2024-04-17 18:43:24 +02:00
this.node.value.save(() => {
this.props.app.nodeModified.value = false
this.node.value.retrieve()
})
2023-06-18 22:05:10 +02:00
}//}}}
renameNode() {//{{{
let name = prompt("New name")
2024-01-09 16:28:40 +01:00
if (!name)
2023-06-18 22:05:10 +02:00
return
2024-01-09 16:28:40 +01:00
this.node.value.rename(name, () => {
2023-06-18 22:05:10 +02:00
this.goToNode(this.node.value.ID)
this.menu.value = false
})
}//}}}
deleteNode() {//{{{
2024-01-09 16:28:40 +01:00
if (!confirm("Do you want to delete this note and all sub-notes?"))
2023-06-18 22:05:10 +02:00
return
2024-01-09 16:28:40 +01:00
this.node.value.delete(() => {
2023-06-18 22:05:10 +02:00
this.goToNode(this.node.value.ParentID)
this.menu.value = false
})
}//}}}
2023-07-01 20:33:26 +02:00
2023-07-12 22:35:38 +02:00
async retrieveKeys() {//{{{
2024-01-09 16:28:40 +01:00
return new Promise((resolve, reject) => {
2023-07-12 22:35:38 +02:00
this.props.app.request('/key/retrieve', {})
2024-01-09 16:28:40 +01:00
.then(res => {
this.keys.value = res.Keys.map(keyData => new Key(keyData, this.keyCounter))
2023-07-12 22:35:38 +02:00
resolve(this.keys.value)
})
.catch(reject)
})
}//}}}
keyCounter() {//{{{
return window._app.current.request('/key/counter', {})
2024-01-09 16:28:40 +01:00
.then(res => BigInt(res.Counter))
2023-07-12 22:35:38 +02:00
.catch(window._app.current.responseError)
}//}}}
getKey(id) {//{{{
let keys = this.keys.value
2024-01-09 16:28:40 +01:00
for (let i = 0; i < keys.length; i++)
if (keys[i].ID == id)
2023-07-12 22:35:38 +02:00
return keys[i]
return null
}//}}}
2023-07-01 20:33:26 +02:00
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
}//}}}
2024-01-09 16:28:40 +01:00
toggleMarkdown() {//{{{
2024-04-03 17:37:32 +02:00
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
2024-01-09 16:28:40 +01:00
}//}}}
2023-06-18 20:13:35 +02:00
}
class NodeContent extends Component {
constructor(props) {//{{{
super(props)
this.contentDiv = createRef()
this.state = {
modified: false,
}
}//}}}
2023-07-12 22:35:38 +02:00
render({ node }) {//{{{
let content = ''
try {
content = node.content()
2024-01-09 16:28:40 +01:00
} catch (err) {
2023-07-12 22:35:38 +02:00
return html`
<div id="node-content" class="node-content encrypted">${err.message}</div>
`
}
2024-01-09 16:28:40 +01:00
var element
if (node.RenderMarkdown.value)
2024-01-09 17:15:05 +01:00
element = html`<${MarkdownContent} key='markdown-content' content=${content} />`
2024-01-09 16:28:40 +01:00
else
element = html`
<div class="grow-wrap">
<textarea id="node-content" class="node-content" ref=${this.contentDiv} oninput=${evt => this.contentChanged(evt)} required rows=1>${content}</textarea>
</div>
`
return element
}//}}}
componentDidMount() {//{{{
this.resize()
2024-01-09 16:28:40 +01:00
window.addEventListener('resize', () => this.resize())
}//}}}
componentDidUpdate() {//{{{
this.resize()
}//}}}
2024-01-09 16:28:40 +01:00
contentChanged(evt) {//{{{
window._app.current.nodeModified.value = true
2024-01-09 16:28:40 +01:00
const content = evt.target.value
this.props.node.setContent(content)
this.resize()
}//}}}
resize() {//{{{
2023-06-27 14:44:36 +02:00
let textarea = document.getElementById('node-content')
2024-01-09 16:28:40 +01:00
if (textarea)
2023-06-27 14:44:36 +02:00
textarea.parentNode.dataset.replicatedValue = textarea.value
2023-06-18 20:13:35 +02:00
}//}}}
2023-07-12 22:35:38 +02:00
unlock() {//{{{
let pass = prompt(`Password for "${this.props.model.description}"`)
2024-01-09 16:28:40 +01:00
if (!pass)
2023-07-12 22:35:38 +02:00
return
try {
this.props.model.unlock(pass)
this.forceUpdate()
2024-01-09 16:28:40 +01:00
} catch (err) {
2023-07-12 22:35:38 +02:00
alert(err)
}
}//}}}
2023-06-17 09:11:14 +02:00
}
2024-01-09 17:15:05 +01:00
class MarkdownContent extends Component {
render({ content }) {//{{{
2024-01-09 17:15:05 +01:00
return html`<div id="markdown"></div>`
}//}}}
2024-01-09 17:15:05 +01:00
componentDidMount() {//{{{
const markdown = document.getElementById('markdown')
if (markdown)
markdown.innerHTML = marked.parse(this.props.content)
}//}}}
}
2024-04-17 18:43:24 +02:00
class NodeEvents extends Component {
render({ events }) {//{{{
if (events.length == 0)
return html``
const eventElements = events.map(evt => {
const dt = evt.Time.split('T')
return html`<div>${dt[0]} ${dt[1].slice(0, 5)}</div>`
})
return html`
<div id="schedule-section">
<div class="header">Schedule events</div>
${eventElements}
</div>
`
}//}}}
}
2023-06-22 06:52:27 +02:00
class NodeFiles extends Component {
2023-06-22 08:28:51 +02:00
render({ node }) {//{{{
2024-01-09 16:28:40 +01:00
if (node.Files === null || node.Files.length == 0)
2023-06-22 08:28:51 +02:00
return
let files = node.Files
2024-01-09 16:28:40 +01:00
.sort((a, b) => {
if (a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1
if (a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1
2023-06-22 08:28:51 +02:00
return 0
})
2024-01-09 16:28:40 +01:00
.map(file =>
2023-06-22 08:28:51 +02:00
html`
2024-01-09 16:28:40 +01:00
<div class="filename" onclick=${() => node.download(file.ID)}>${file.Filename}</div>
2023-06-22 08:28:51 +02:00
<div class="size">${this.formatSize(file.Size)}</div>
`
)
return html`
<div id="file-section">
<div class="header">Files</div>
<div class="files">
${files}
</div>
</div>
`
}//}}}
formatSize(size) {//{{{
2024-01-09 16:28:40 +01:00
if (size < 1048576) {
2023-06-22 08:28:51 +02:00
return `${Math.round(size / 1024)} KiB`
} else {
return `${Math.round(size / 1048576)} MiB`
}
}//}}}
2023-06-22 06:52:27 +02:00
}
2023-06-27 14:44:36 +02:00
export class Node {
2023-06-17 09:11:14 +02:00
constructor(app, nodeID) {//{{{
2024-01-09 16:28:40 +01:00
this.app = app
this.ID = nodeID
this.ParentID = 0
this.UserID = 0
2023-07-12 22:35:38 +02:00
this.CryptoKeyID = 0
2024-01-09 16:28:40 +01:00
this.Name = ''
this.RenderMarkdown = signal(false)
this.Markdown = false
this.ShowChecklist = signal(false)
2024-01-09 16:28:40 +01:00
this._content = ''
this.Children = []
this.Crumbs = []
this.Files = []
2023-07-12 22:35:38 +02:00
this._decrypted = false
2023-06-27 14:44:36 +02:00
this._expanded = false // start value for the TreeNode component,
this.ChecklistGroups = {}
2024-04-17 18:43:24 +02:00
this.ScheduleEvents = signal([])
2024-01-09 16:28:40 +01:00
// it doesn't control it afterwards.
// Used to expand the crumbs upon site loading.
2023-06-17 09:11:14 +02:00
}//}}}
retrieve(callback) {//{{{
2024-04-17 18:43:24 +02:00
this.app.request('/schedule/list', { NodeID: this.ID })
.then(res => {
this.ScheduleEvents.value = res.ScheduleEvents
})
2023-06-17 09:11:14 +02:00
this.app.request('/node/retrieve', { ID: this.ID })
2024-01-09 16:28:40 +01:00
.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)
2024-01-09 16:28:40 +01:00
callback(this)
})
.catch(this.app.responseError)
2023-06-17 09:11:14 +02:00
}//}}}
2023-06-22 07:10:26 +02:00
delete(callback) {//{{{
this.app.request('/node/delete', {
NodeID: this.ID,
})
2024-01-09 16:28:40 +01:00
.then(callback)
.catch(this.app.responseError)
2023-06-22 07:10:26 +02:00
}//}}}
create(name, callback) {//{{{
this.app.request('/node/create', {
Name: name.trim(),
ParentID: this.ID,
})
2024-01-09 16:28:40 +01:00
.then(res => {
callback(res.Node.ID)
})
.catch(this.app.responseError)
2023-06-22 07:10:26 +02:00
}//}}}
2023-07-14 16:17:37 +02:00
async save(callback) {//{{{
try {
await this.#encrypt()
2023-07-12 22:35:38 +02:00
2023-07-14 16:17:37 +02:00
let req = {
NodeID: this.ID,
Content: this._content,
CryptoKeyID: this.CryptoKeyID,
2024-01-09 16:28:40 +01:00
Markdown: this.Markdown,
2024-03-30 09:46:48 +01:00
TimeOffset: -(new Date().getTimezoneOffset()),
2023-07-14 16:17:37 +02:00
}
this.app.request('/node/update', req)
.then(callback)
.catch(this.app.responseError)
} catch (err) {
this.app.responseError(err)
}
2023-06-22 07:10:26 +02:00
}//}}}
rename(name, callback) {//{{{
this.app.request('/node/rename', {
Name: name.trim(),
NodeID: this.ID,
})
2024-01-09 16:28:40 +01:00
.then(callback)
.catch(this.app.responseError)
2023-06-22 07:10:26 +02:00
}//}}}
2023-06-22 16:48:31 +02:00
download(fileID) {//{{{
let headers = {
'Content-Type': 'application/json',
}
2024-01-09 16:28:40 +01:00
if (this.app.session.UUID !== '')
2023-06-22 16:48:31 +02:00
headers['X-Session-Id'] = this.app.session.UUID
let fname = ""
fetch("/node/download", {
method: 'POST',
headers,
body: JSON.stringify({
NodeID: this.ID,
FileID: fileID,
}),
})
2024-01-09 16:28:40 +01:00
.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
})
2023-06-22 16:48:31 +02:00
}//}}}
2023-07-12 22:35:38 +02:00
content() {//{{{
2024-01-09 16:28:40 +01:00
if (this.CryptoKeyID != 0 && !this._decrypted)
2023-07-12 22:35:38 +02:00
this.#decrypt()
return this._content
}//}}}
2023-07-14 16:17:37 +02:00
setContent(new_content) {//{{{
this._content = new_content
2024-01-09 16:28:40 +01:00
if (this.CryptoKeyID == 0)
2023-07-14 16:17:37 +02:00
// 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)
2023-07-12 22:35:38 +02:00
}//}}}
#decrypt() {//{{{
2024-01-09 16:28:40 +01:00
if (this.CryptoKeyID == 0 || this._decrypted)
2023-07-14 16:17:37 +02:00
return
2023-07-12 22:35:38 +02:00
let obj_key = this.app.nodeUI.current.getKey(this.CryptoKeyID)
2024-01-09 16:28:40 +01:00
if (obj_key === null || obj_key.ID != this.CryptoKeyID)
throw ('Invalid key')
2023-07-12 22:35:38 +02:00
// Ask user to unlock key first
var pass = null
2024-01-09 16:28:40 +01:00
while (pass || obj_key.status() == 'locked') {
pass = prompt(`Password for "${obj_key.description}"`)
2024-01-09 16:28:40 +01:00
if (!pass)
2023-07-12 22:35:38 +02:00
throw new Error(`Key "${obj_key.description}" is locked`)
try {
obj_key.unlock(pass)
2024-01-09 16:28:40 +01:00
} catch (err) {
2023-07-12 22:35:38 +02:00
alert(err)
}
pass = null
}
2024-01-09 16:28:40 +01:00
if (obj_key.status() == 'locked')
2023-07-12 22:35:38 +02:00
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)
)
}//}}}
2023-07-14 16:17:37 +02:00
async #encrypt(change_key = false, new_key = null) {//{{{
// Nothing to do if not changing key and already encrypted.
2024-01-09 16:28:40 +01:00
if (!change_key && this.CryptoKeyID != 0 && !this._decrypted)
2023-07-14 16:17:37 +02:00
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.
2024-01-09 16:28:40 +01:00
if ((change_key && new_key === null) || (!change_key && this.CryptoKeyID == 0)) {
2023-07-14 16:17:37 +02:00
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)
2024-01-09 16:28:40 +01:00
if (obj_key === null || obj_key.ID != key_id)
throw ('Invalid key')
2023-07-12 22:35:38 +02:00
2024-01-09 16:28:40 +01:00
if (obj_key.status() == 'locked')
2023-07-12 22:35:38 +02:00
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()
2023-07-14 16:17:37 +02:00
this.CryptoKeyID = obj_key.ID
this._content = crypto.encrypt(content_bits, counter, true)
this._decrypted = false
return this._content
2023-07-12 22:35:38 +02:00
}//}}}
initChecklist(checklistData) {//{{{
if (checklistData === undefined || checklistData === null)
return
2024-04-03 17:37:32 +02:00
this.ChecklistGroups = checklistData.map(groupData => {
return new ChecklistGroup(groupData)
})
}//}}}
2023-06-17 09:11:14 +02:00
}
2023-06-21 23:52:21 +02:00
class Menu extends Component {
2023-06-22 06:52:27 +02:00
render({ nodeui }) {//{{{
2023-06-21 23:52:21 +02:00
return html`
2024-01-09 16:28:40 +01:00
<div id="blackout" onclick=${() => nodeui.menu.value = false}></div>
2023-06-22 06:52:27 +02:00
<div id="menu">
2023-07-20 10:47:49 +02:00
<div class="section">Current note</div>
2024-01-09 16:28:40 +01:00
<div class="item" onclick=${() => { nodeui.renameNode(); nodeui.menu.value = false }}>Rename</div>
<div class="item" onclick=${() => { nodeui.showPage('upload'); nodeui.menu.value = false }}>Upload</div>
<div class="item " onclick=${() => { nodeui.showPage('node-properties'); nodeui.menu.value = false }}>Properties</div>
<div class="item separator" onclick=${() => { nodeui.deleteNode(); nodeui.menu.value = false }}>Delete</div>
2023-07-20 10:47:49 +02:00
<div class="section">User</div>
2024-01-09 16:28:40 +01:00
<div class="item" onclick=${() => { nodeui.showPage('profile-settings'); nodeui.menu.value = false }}>Settings</div>
<div class="item" onclick=${() => { nodeui.logout(); nodeui.menu.value = false }}>Log out</div>
2023-06-21 23:52:21 +02:00
</div>
`
}//}}}
}
class UploadUI extends Component {
2023-06-22 08:28:51 +02:00
constructor(props) {//{{{
super(props)
2023-06-21 23:52:21 +02:00
this.file = createRef()
this.filelist = signal([])
this.fileRefs = []
this.progressRefs = []
}//}}}
render({ nodeui }) {//{{{
let filelist = this.filelist.value
let files = []
2024-01-09 16:28:40 +01:00
for (let i = 0; i < filelist.length; i++) {
2023-06-21 23:52:21 +02:00
files.push(html`<div key=file_${i} ref=${this.fileRefs[i]} class="file">${filelist.item(i).name}</div><div class="progress" ref=${this.progressRefs[i]}></div>`)
}
return html`
2024-01-09 16:28:40 +01:00
<div id="blackout" onclick=${() => nodeui.showPage('node')}></div>
2023-06-21 23:52:21 +02:00
<div id="upload">
2024-01-09 16:28:40 +01:00
<input type="file" ref=${this.file} onchange=${() => this.upload()} multiple />
2023-06-21 23:52:21 +02:00
<div class="files">
${files}
</div>
</div>
`
}//}}}
componentDidMount() {//{{{
this.file.current.focus()
}//}}}
upload() {//{{{
2023-06-22 06:52:27 +02:00
let nodeID = this.props.nodeui.node.value.ID
2023-06-21 23:52:21 +02:00
this.fileRefs = []
this.progressRefs = []
let input = this.file.current
this.filelist.value = input.files
2024-01-09 16:28:40 +01:00
for (let i = 0; i < input.files.length; i++) {
2023-06-21 23:52:21 +02:00
this.fileRefs.push(createRef())
this.progressRefs.push(createRef())
2023-06-22 06:52:27 +02:00
this.postFile(
input.files[i],
nodeID,
2024-01-09 16:28:40 +01:00
progress => {
2023-06-22 06:52:27 +02:00
this.progressRefs[i].current.innerHTML = `${progress}%`
},
2024-01-09 16:28:40 +01:00
res => {
2023-06-22 08:28:51 +02:00
this.props.nodeui.node.value.Files.push(res.File)
this.props.nodeui.forceUpdate()
2023-06-22 06:52:27 +02:00
this.fileRefs[i].current.classList.add("done")
this.progressRefs[i].current.classList.add("done")
2023-06-22 08:28:51 +02:00
2024-01-05 21:28:14 +01:00
this.props.nodeui.showPage('node')
2023-06-22 06:52:27 +02:00
})
2023-06-21 23:52:21 +02:00
}
}//}}}
2023-06-22 06:52:27 +02:00
postFile(file, nodeID, progressCallback, doneCallback) {//{{{
2023-06-21 23:52:21 +02:00
var formdata = new FormData()
formdata.append('file', file)
2023-06-22 06:52:27 +02:00
formdata.append('NodeID', nodeID)
2023-06-21 23:52:21 +02:00
var request = new XMLHttpRequest()
2024-01-09 16:28:40 +01:00
request.addEventListener("error", () => {
2023-06-21 23:52:21 +02:00
window._app.current.responseError({ upload: "An unknown error occured" })
})
2024-01-09 16:28:40 +01:00
request.addEventListener("loadend", () => {
if (request.status != 200) {
2023-06-21 23:52:21 +02:00
window._app.current.responseError({ upload: request.statusText })
return
}
let response = JSON.parse(request.response)
2024-01-09 16:28:40 +01:00
if (!response.OK) {
2023-06-21 23:52:21 +02:00
window._app.current.responseError({ upload: response.Error })
return
}
2023-06-22 08:28:51 +02:00
doneCallback(response)
2023-06-21 23:52:21 +02:00
})
2024-01-09 16:28:40 +01:00
request.upload.addEventListener('progress', evt => {
2023-06-21 23:52:21 +02:00
var fileSize = file.size
2024-01-09 16:28:40 +01:00
if (evt.loaded <= fileSize)
2023-06-21 23:52:21 +02:00
progressCallback(Math.round(evt.loaded / fileSize * 100))
2024-01-09 16:28:40 +01:00
if (evt.loaded == evt.total)
2023-06-21 23:52:21 +02:00
progressCallback(100)
2024-01-09 16:28:40 +01:00
})
2023-06-21 23:52:21 +02:00
request.open('post', '/node/upload')
request.setRequestHeader("X-Session-Id", window._app.current.session.UUID)
//request.timeout = 45000
request.send(formdata)
}//}}}
}
2023-07-01 20:33:26 +02:00
class NodeProperties extends Component {
constructor(props) {//{{{
super(props)
2023-07-12 22:35:38 +02:00
this.props.nodeui.retrieveKeys()
this.selected_key_id = 0
2023-07-01 20:33:26 +02:00
}//}}}
render({ nodeui }) {//{{{
2023-07-12 22:35:38 +02:00
let keys = nodeui.keys.value
2024-01-09 16:28:40 +01:00
.sort((a, b) => {
if (a.description < b.description) return -1
if (a.description > b.description) return 1
2023-07-12 22:35:38 +02:00
return 0
})
2024-01-09 16:28:40 +01:00
.map(key => {
this.props.nodeui.keys.value.some(uikey => {
if (uikey.ID == nodeui.node.value.ID) {
this.selected_key_id = nodeui.node.value.ID
2023-07-12 22:35:38 +02:00
return true
}
})
2024-01-09 16:28:40 +01:00
if (nodeui.node.value.CryptoKeyID == key.ID)
this.selected_key_id = key.ID
2023-07-12 22:35:38 +02:00
return html`
<div class="key ${key.status()}">
2024-01-09 16:28:40 +01:00
<input type="radio" name="key" id="key-${key.ID}" checked=${nodeui.node.value.CryptoKeyID == key.ID} disabled=${key.status() == 'locked'} oninput=${() => this.selected_key_id = key.ID} />
2023-07-12 22:35:38 +02:00
<label for="key-${key.ID}">${key.description}</label>
</div>`
})
2023-07-01 20:33:26 +02:00
return html`
<div id="properties">
2023-07-12 22:35:38 +02:00
<h1>Note properties</h1>
<div style="margin-bottom: 16px">These properties are only for this note.</div>
2023-07-12 22:35:38 +02:00
<div class="checks">
2024-04-03 17:37:32 +02:00
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt => nodeui.node.value.Markdown = evt.target.checked} />
<label for="render-markdown">Markdown view</label>
</div>
2024-01-09 16:28:40 +01:00
2023-07-12 22:35:38 +02:00
<h2>Encryption</h2>
<div class="key">
2024-01-09 16:28:40 +01:00
<input type="radio" id="key-none" name="key" checked=${nodeui.node.value.CryptoKeyID == 0} oninput=${() => this.selected_key_id = 0} />
2023-07-12 22:35:38 +02:00
<label for="key-none">None</label>
</div>
${keys}
2024-01-09 16:28:40 +01:00
<button style="margin-top: 32px" onclick=${() => this.save()}>Save</button>
2023-07-01 20:33:26 +02:00
</div>
`
}//}}}
2023-07-14 16:17:37 +02:00
async save() {//{{{
2023-07-12 22:35:38 +02:00
let nodeui = this.props.nodeui
let node = nodeui.node.value
// Find the actual key object used for encryption
2023-07-14 16:17:37 +02:00
let new_key = nodeui.getKey(this.selected_key_id)
let current_key = nodeui.getKey(node.CryptoKeyID)
2023-07-12 22:35:38 +02:00
2024-01-09 16:28:40 +01:00
if (current_key && current_key.status() == 'locked') {
2023-07-12 22:35:38 +02:00
alert("Decryption key is locked and can not be used.")
return
}
2024-01-09 16:28:40 +01:00
if (new_key && new_key.status() == 'locked') {
2023-07-12 22:35:38 +02:00
alert("Key is locked and can not be used.")
return
}
2023-07-14 16:17:37 +02:00
await node.setCryptoKey(new_key)
2024-01-09 16:28:40 +01:00
if (node.Markdown != node.RenderMarkdown.value)
node.RenderMarkdown.value = node.Markdown
node.save(() => this.props.nodeui.showPage('node'))
2023-07-12 22:35:38 +02:00
}//}}}
2023-07-01 20:33:26 +02:00
}
2023-06-21 23:52:21 +02:00
2023-07-19 10:00:36 +02:00
class Search extends Component {
constructor() {//{{{
super()
this.state = {
matches: [],
results_returned: false,
}
}//}}}
render({ nodeui }, { matches, results_returned }) {//{{{
let match_elements = [
html`<h2>Results</h2>`,
]
2024-01-09 16:28:40 +01:00
let matched_nodes = matches.map(node => html`
<div class="matched-node" onclick=${() => nodeui.goToNode(node.ID)}>
2023-07-19 10:00:36 +02:00
${node.Name}
</div>
`)
match_elements.push(html`<div class="matches">${matched_nodes}</div>`)
return html`
<div id="search">
<h1>Search</h1>
2024-01-09 16:28:40 +01:00
<input type="text" id="search-for" placeholder="Search for" onkeydown=${evt => this.keyHandler(evt)} />
<button onclick=${() => this.search()}>Search</button>
2023-07-19 10:00:36 +02:00
${results_returned ? match_elements : ''}
</div>`
}//}}}
componentDidMount() {//{{{
document.getElementById('search-for').focus()
}//}}}
keyHandler(evt) {//{{{
let handled = true
2024-01-09 16:28:40 +01:00
switch (evt.key.toUpperCase()) {
2023-07-19 10:00:36 +02:00
case 'ENTER':
this.search()
break
default:
handled = false
}
2024-01-09 16:28:40 +01:00
if (handled) {
2023-07-19 10:00:36 +02:00
evt.preventDefault()
evt.stopPropagation()
}
}//}}}
search() {//{{{
let Search = document.getElementById('search-for').value
window._app.current.request('/node/search', { Search })
2024-01-09 16:28:40 +01:00
.then(res => {
this.setState({
matches: res.Nodes,
results_returned: true,
2023-07-19 10:00:36 +02:00
2024-01-09 16:28:40 +01:00
})
2023-07-19 10:00:36 +02:00
})
2024-01-09 16:28:40 +01:00
.catch(window._app.current.responseError)
2023-07-19 10:00:36 +02:00
}//}}}
}
2023-07-20 10:47:49 +02:00
class ProfileSettings extends Component {
2024-01-09 16:28:40 +01:00
render({ nodeui }, { }) {//{{{
2023-07-20 10:47:49 +02:00
return html`
<div id="profile-settings">
<h1>User settings</h1>
<h2>Password</h2>
<div class="passwords">
<div>Current</div>
2024-01-09 16:28:40 +01:00
<input type="password" id="current-password" placeholder="Current password" onkeydown=${evt => this.keyHandler(evt)} />
2023-07-20 10:47:49 +02:00
<div>New</div>
2024-01-09 16:28:40 +01:00
<input type="password" id="new-password1" placeholder="Password" onkeydown=${evt => this.keyHandler(evt)} />
2023-07-20 10:47:49 +02:00
<div>Repeat</div>
2024-01-09 16:28:40 +01:00
<input type="password" id="new-password2" placeholder="Repeat password" onkeydown=${evt => this.keyHandler(evt)} />
2023-07-20 10:47:49 +02:00
</div>
2024-01-09 16:28:40 +01:00
<button onclick=${() => this.updatePassword()}>Change password</button>
2023-07-20 10:47:49 +02:00
</div>`
}//}}}
componentDidMount() {//{{{
document.getElementById('current-password').focus()
}//}}}
keyHandler(evt) {//{{{
let handled = true
2024-01-09 16:28:40 +01:00
switch (evt.key.toUpperCase()) {
2023-07-20 10:47:49 +02:00
case 'ENTER':
this.updatePassword()
break
default:
handled = false
}
2024-01-09 16:28:40 +01:00
if (handled) {
2023-07-20 10:47:49 +02:00
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 {
2024-01-09 16:28:40 +01:00
if (pass1.length < 4) {
2023-07-20 10:47:49 +02:00
throw new Error('Password has to be at least 4 characters long')
}
2024-01-09 16:28:40 +01:00
if (pass1 != pass2) {
2023-07-20 10:47:49 +02:00
throw new Error(`Passwords don't match`)
}
window._app.current.request('/user/password', {
CurrentPassword: curr_pass,
NewPassword: pass1,
})
2024-01-09 16:28:40 +01:00
.then(res => {
if (res.CurrentPasswordOK)
alert('Password is changed successfully')
else
alert('Current password is invalid')
})
} catch (err) {
2023-07-20 10:47:49 +02:00
alert(err.message)
}
}//}}}
}
2024-04-03 17:37:32 +02:00
class ScheduleEventList extends Component {
2024-04-19 18:32:37 +02:00
static CALENDAR = Symbol('CALENDAR')
static LIST = Symbol('LIST')
constructor() {//{{{
super()
this.tab = signal(ScheduleEventList.CALENDAR)
}//}}}
render() {//{{{
var tab
switch (this.tab.value) {
case ScheduleEventList.CALENDAR:
tab = html`<${ScheduleCalendarTab} />`
break;
case ScheduleEventList.LIST:
tab = html`<${ScheduleEventListTab} />`
break;
}
return html`
<div style="margin: 32px">
<div class="folder">
<div class="tabs">
<div onclick=${() => this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar</div>
<div onclick=${() => this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List</div>
<div class="hack"></div>
</div>
<div class="content">
${tab}
</div>
</div>
</div>
`
}//}}}
}
class ScheduleEventListTab extends Component {
2024-04-17 18:43:24 +02:00
constructor() {//{{{
2024-04-03 17:37:32 +02:00
super()
2024-04-17 18:43:24 +02:00
this.events = signal(null)
2024-04-03 17:37:32 +02:00
this.retrieveFutureEvents()
2024-04-17 18:43:24 +02:00
}//}}}
render() {//{{{
if (this.events.value === null)
return
2024-04-19 18:32:37 +02:00
let events = this.events.value.sort((a, b) => {
if (a.Time < b.Time) return -1
if (a.Time > b.Time) return 1
return 0
}).map(evt => {
console.log(evt)
2024-04-17 18:43:24 +02:00
const dt = evt.Time.split('T')
const remind = () => {
if (evt.RemindMinutes > 0)
return html`${evt.RemindMinutes} min`
}
const nodeLink = () => html`<a href="/?node=${evt.Node.ID}">${evt.Node.Name}</a>`
return html`
<div class="date">${dt[0]}</div>
<div class="time">${dt[1].slice(0, 5)}</div>
<div class="remind"><${remind} /></div>
<div class="description">${evt.Description}</div>
<div class="node"><${nodeLink} /></div>
`
2024-04-03 17:37:32 +02:00
})
2024-04-17 18:43:24 +02:00
return html`
<div id="schedule-events">
<div class="header">Date</div>
<div class="header">Time</div>
<div class="header">Reminder</div>
<div class="header">Event</div>
<div class="header">Node</div>
${events}
</div>
`
}//}}}
retrieveFutureEvents() {//{{{
_app.current.request('/schedule/list')
.then(data => {
this.events.value = data.ScheduleEvents
})
}//}}}
2024-04-03 17:37:32 +02:00
}
2024-04-19 18:32:37 +02:00
class ScheduleCalendarTab extends Component {
constructor() {//{{{
super()
}//}}}
componentDidMount() {
let calendarEl = document.getElementById('fullcalendar');
this.calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
events: this.events,
eventTimeFormat: {
hour12: false,
hour: '2-digit',
minute: '2-digit',
},
firstDay: 1,
});
this.calendar.render();
}
render() {
return html`<div id="fullcalendar"></div>`
}
events(info, successCallback, failureCallback) {
const req = {
StartDate: info.startStr,
EndDate: info.endStr,
}
_app.current.request('/schedule/list', req)
.then(data => {
const fullcalendarEvents = data.ScheduleEvents.map(sch => {
return {
title: sch.Description,
start: sch.Time,
url: `/?node=${sch.Node.ID}`,
}
})
successCallback(fullcalendarEvents)
})
}
}
2024-04-03 17:37:32 +02:00
2023-06-17 09:11:14 +02:00
// vim: foldmethod=marker