Compare commits

...

8 commits
v27 ... main

Author SHA1 Message Date
Magnus Åhall
74851b9c4d Prefs managing mostly done 2026-06-21 10:59:38 +02:00
Magnus Åhall
81d02b82dc Small refactor for user preferences 2026-06-18 09:21:23 +02:00
Magnus Åhall
1a712fb7a9 Fix home/end to account for special pages 2026-06-16 10:20:15 +02:00
Magnus Åhall
f5cbfb0b22 Fixed regression of recursive expansion toggle 2026-06-16 10:12:48 +02:00
Magnus Åhall
ea3bdaca03 Select leaf nodes on icon 2026-06-16 10:10:16 +02:00
Magnus Åhall
0fe5cd78b3 Removed vi keybindings to enable search at a later date. 2026-06-16 10:08:36 +02:00
Magnus Åhall
d6d8b64bb9 Fixed recursive open of nodes, bumped to v29 2026-06-16 09:59:01 +02:00
Magnus Åhall
c36b4ace13 Nicer code copy, bumped to v28 2026-06-16 09:43:31 +02:00
16 changed files with 576 additions and 178 deletions

View file

@ -8,6 +8,9 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq" "github.com/lib/pq"
// Internal
appUser "notes2/user"
// Standard // Standard
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
@ -27,12 +30,6 @@ type Manager struct {
ExpireDays int ExpireDays int
} }
type User struct {
ID int
Username string
Name string
}
func httpError(w http.ResponseWriter, err error) { // {{{ func httpError(w http.ResponseWriter, err error) { // {{{
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool OK bool
@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
mngr.log.Info("authentication", "username", request.Username, "status", "accepted") mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool OK bool
User User User appUser.User
Token string Token string
}{true, user, token}) }{true, user, token})
w.Write(j) w.Write(j)
} // }}} } // }}}
func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{
var row *sql.Row var row *sql.Row
row = mngr.db.QueryRow(` row = mngr.db.QueryRow(`
SELECT id, username, name SELECT id, username, name, preferences
FROM public.user FROM public.user
WHERE WHERE
LOWER(username) = LOWER($1) AND LOWER(username) = LOWER($1) AND
@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
username, username,
password, password,
) )
err = row.Scan(&user.ID, &user.Username, &user.Name) var data []byte
err = row.Scan(&user.ID, &user.Username, &user.Name, &data)
if err != nil && err.Error() == "sql: no rows in result set" { if err != nil && err.Error() == "sql: no rows in result set" {
err = nil err = nil
authenticated = false authenticated = false
return return
} }
if err != nil { if err != nil {
authenticated = false
return
}
err = json.Unmarshal(data, &user.Preferences)
if err != nil {
authenticated = false
return return
} }
@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin
changed = (rowsAffected == 1) changed = (rowsAffected == 1)
return return
} // }}} } // }}}
func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{ func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{
// Each client session has its own UUID. // Each client session has its own UUID.
// Loop through until a unique one is established. // Loop through until a unique one is established.
var proposedClientUUID string var proposedClientUUID string

69
main.go
View file

@ -4,6 +4,7 @@ import (
// Internal // Internal
"notes2/authentication" "notes2/authentication"
"notes2/html_template" "notes2/html_template"
appUser "notes2/user"
"os" "os"
// Standard // Standard
@ -23,7 +24,7 @@ import (
"text/template" "text/template"
) )
const VERSION = "v27" const VERSION = "v29"
const CONTEXT_USER = 1 const CONTEXT_USER = 1
const SYNC_PAGINATION = 200 const SYNC_PAGINATION = 200
@ -134,6 +135,8 @@ func main() { // {{{
http.HandleFunc("/offline", pageOffline) http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
} }
// User object is added to the context for the next handler. // User object is added to the context for the next handler.
user := NewUser(claims) user := appUser.NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user)) r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid // The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that // sending nodes back once again to a client that
// just created or modified it. // just created or modified it.
user := getUser(r) user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset")) offset, _ := strconv.Atoi(r.PathValue("offset"))
@ -289,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid // The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that // sending nodes back once again to a client that
// just created or modified it. // just created or modified it.
user := getUser(r) user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
@ -309,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j) w.Write(j)
} // }}} } // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -325,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -348,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
var err error var err error
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
@ -360,12 +363,12 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
} }
responseData(w, map[string]any{ responseData(w, map[string]any{
"OK": true, "OK": true,
"Count": count, "Count": count,
}) })
} // }}} } // }}}
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUserSession(r)
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var request struct { var request struct {
@ -389,6 +392,47 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
}) })
} // }}} } // }}}
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
prefs, err := user.Preferences()
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Preferences": prefs,
})
} // }}}
func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
// Verify the "default" profile is still there.
var newPrefs map[string]appUser.UserPreferences
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &newPrefs)
if err != nil {
httpError(w, err)
return
}
if _, found := newPrefs["default"]; !found {
httpError(w, fmt.Errorf("'default' profile missing."))
return
}
err = session.SetPreferences(newPrefs)
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func createNewUser(username string) { // {{{ func createNewUser(username string) { // {{{
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@ -431,7 +475,8 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n") fmt.Printf("\nPassword changed\n")
} // }}} } // }}}
func getUser(r *http.Request) UserSession { // {{{ func getUserSession(r *http.Request) appUser.UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(UserSession) user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
user.Db = db
return user return user
} // }}} } // }}}

1
sql/00010.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL;

View file

@ -73,9 +73,10 @@ button {
1fr; 1fr;
} }
&.page-history { /* The other pages just gets the whole page without dividing it up. */
&:not(.page-node) {
grid-template-areas: grid-template-areas:
"tree-expander tree pad1 n2-pagehistory pad2" "tree-expander tree pad1 n2-page pad2"
; ;
grid-template-columns: grid-template-columns:
@ -245,7 +246,6 @@ button {
#notes2 { #notes2 {
&.page-node { &.page-node {
#page-root { #page-root {
display: none; display: none;
} }
@ -260,7 +260,7 @@ button {
display: contents; display: contents;
n2-pagestorage { n2-pagestorage {
grid-area: content; grid-area: n2-page;
} }
} }
} }
@ -268,9 +268,14 @@ button {
&.page-history { &.page-history {
#page-history { #page-history {
display: grid; display: grid;
grid-area: n2-pagehistory; grid-area: n2-page;
}
}
n2-pagehistory {} &.page-preferences {
#page-preferences {
display: block;
grid-area: n2-page;
} }
} }
@ -282,7 +287,6 @@ button {
#page-root { #page-root {
display: contents !important; display: contents !important;
} }
} }
} }

View file

@ -2,18 +2,18 @@
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="26.666645" width="12"
height="24" height="23.999981"
viewBox="0 0 7.0555498 6.35" viewBox="0 0 3.1750001 6.349995"
version="1.1" version="1.1"
id="svg1" id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="icon_menu.svg" sodipodi:docname="icon_menu.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"> xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
<sodipodi:namedview
id="namedview1" id="namedview1"
pagecolor="#ffffff" pagecolor="#ffffff"
bordercolor="#000000" bordercolor="#000000"
@ -23,29 +23,34 @@
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="2.096401" inkscape:zoom="11.859035"
inkscape:cx="10.255672" inkscape:cx="8.6010372"
inkscape:cy="9.0631517" inkscape:cy="17.32856"
inkscape:window-width="1916" inkscape:window-width="2190"
inkscape:window-height="1041" inkscape:window-height="1401"
inkscape:window-x="1920" inkscape:window-x="1463"
inkscape:window-y="1080" inkscape:window-y="18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1" /><defs
<defs id="defs1" /><g
id="defs1" />
<g
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1" id="layer1"
transform="translate(-146.57917,-92.339583)"> transform="translate(-147.15925,-92.339586)"><title
<title id="title1">menu</title><title
id="title1">menu</title> id="title1-6">hamburger</title><circle
<title style="fill:#000000;stroke:none;stroke-width:0.264583"
id="title1-6">hamburger</title> id="path3"
<path cx="149.55338"
d="m 153.63472,95.867362 c 0,0.391582 -0.31398,0.705554 -0.70555,0.705554 h -5.64445 c -0.38806,0 -0.70555,-0.313972 -0.70555,-0.705554 0,-0.391584 0.31749,-0.705556 0.70555,-0.705556 h 3.175 l 0.88194,0.705556 0.88195,-0.705556 h 0.70556 c 0.39157,0 0.70555,0.3175 0.70555,0.705556 m -3.52778,-3.527779 c -3.175,0 -3.175,2.116667 -3.175,2.116667 h 6.35 c 0,0 0,-2.116667 -3.175,-2.116667 m -3.175,5.291667 c 0,0.585612 0.47272,1.058333 1.05834,1.058333 h 4.23333 c 0.58561,0 1.05833,-0.472721 1.05833,-1.058333 v -0.352778 h -6.35 z" cy="93.120461"
id="path1-2" r="0.78087437" /><circle
style="stroke-width:0.352777" /> style="fill:#000000;stroke:none;stroke-width:0.264583"
</g> id="circle4"
</svg> cx="149.55338"
cy="97.908707"
r="0.78087437" /><circle
style="fill:#000000;stroke:none;stroke-width:0.264583"
id="circle5"
cx="149.55338"
cy="95.514587"
r="0.78087437" /></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

View file

@ -1,7 +1,7 @@
export class API { export class API {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) { static async query(method, path, request) {
return new Promise((resolve, reject) => { try {
const body = JSON.stringify(request) const body = JSON.stringify(request)
const headers = {} const headers = {}
@ -12,33 +12,22 @@ export class API {
headers.Authorization = `Bearer ${token}` headers.Authorization = `Bearer ${token}`
} }
fetch(path, { method, headers, body }) const res = await fetch(path, { method, headers, body })
.then(response => { // An HTTP communication level error occured.
// An HTTP communication level error occured. if (!res.ok || res.status != 200)
if (!response.ok || response.status != 200) throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
return reject({
type: 'http', // Application level response are handled here.
error: response, const json = await res.json()
}) if (!json.OK)
return response.json() throw new Error(json.Error, { cause: { type: 'application', application: json, }})
})
.then(json => { return json
// Application level response are handled here.
if (!json.OK) } catch (err) {
return reject({ // Catch any other errors from fetch.
type: 'application', throw new Error(err.message, { cause: { type: 'http', error: err, }})
error: json.Error, }
application: json,
})
resolve(json)
})
.catch(err =>
// Catch any other errors from fetch.
reject({
type: 'http',
error: err,
}))
})
} }
static hasAuthenticationToken() {//{{{ static hasAuthenticationToken() {//{{{

View file

@ -2,6 +2,7 @@ import { ROOT_NODE } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { N2Sidebar } from 'sidebar' import { N2Sidebar } from 'sidebar'
import { Node } from 'node' import { Node } from 'node'
import { N2PreferenceSet } from './page_preferences.mjs'
export class App { export class App {
static PAGES = ['node', 'history', 'storage'] static PAGES = ['node', 'history', 'storage']
@ -14,6 +15,8 @@ export class App {
this.nodeUI = document.getElementById('note') this.nodeUI = document.getElementById('note')
this.dragIcon = new N2DragIcon() this.dragIcon = new N2DragIcon()
this.preferences = this.getPreferences()
this.sidebar.render().then(sidebar => { this.sidebar.render().then(sidebar => {
document.getElementById('tree').append(sidebar) document.getElementById('tree').append(sidebar)
document.getElementById('tree-nodes')?.focus() document.getElementById('tree-nodes')?.focus()
@ -61,6 +64,11 @@ export class App {
classList.add('page-' + page) classList.add('page-' + page)
}) })
_mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{
this.preferences = this.getPreferences()
console.log(this.preferences.data)
})
window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('keydown', event => this.keyHandler(event))
window.addEventListener('popstate', event => this.popState(event)) window.addEventListener('popstate', event => this.popState(event))
document.getElementById('notes2').addEventListener('click', event => { document.getElementById('notes2').addEventListener('click', event => {
@ -211,6 +219,12 @@ export class App {
let classList = document.querySelector('#main-page').classList let classList = document.querySelector('#main-page').classList
return classList.contains(page) return classList.contains(page)
}// }}} }// }}}
getPreferences() {// {{{
const devPrefSet = localStorage.getItem('device_preference_set') || 'default'
const userData = localStorage.getItem('user') || '{"default": {}}'
const user = JSON.parse(userData)
return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet])
}// }}}
} }
class N2Crumbs extends CustomHTMLElement { class N2Crumbs extends CustomHTMLElement {

View file

@ -1,7 +1,17 @@
/* Use data-el or data-field attribute.
* Element with data-el="hum-ding" is accessible as this.elHumDing and fields with
* data-field="long-dong" as this.fieldLongDong.
*
* All field values can be retrieved with fieldValues() and uses the data-field attribute
* as LongDong as key.
*/
export class CustomHTMLElement extends HTMLElement { export class CustomHTMLElement extends HTMLElement {
constructor(useShadow) {// {{{ constructor(useShadow) {// {{{
super() super()
this._fields = new Map()
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
workOn.querySelectorAll('*').forEach(el => { workOn.querySelectorAll('*').forEach(el => {
@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement {
if (field !== undefined) { if (field !== undefined) {
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)
} }
const name = el.dataset.el const name = el.dataset.el
@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement {
} }
}) })
}// }}} }// }}}
allFields() {// {{{
return this._fields
}// }}}
fieldValues() {// {{{
const state = {}
for (const [name, field] of this._fields) {
if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
state[name] = field.checked
else
state[name] = field.value
}
return state
}// }}}
toElementName(prefix, str) {// {{{ toElementName(prefix, str) {// {{{
str = prefix + '-' + str str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}} }// }}}
} }
export class StupidPreactCustomHTMLElement extends HTMLElement {
constructor() {// {{{
super()
// Stupid stuff because of Preact.
this.clonedNodes = this.constructor.tmpl.content.cloneNode(true)
this.clonedNodes.querySelectorAll('*').forEach(el => {
const field = el.dataset.field
if (field !== undefined) {
const fieldName = this.toElementName('field', field)
this[fieldName] = el
}
const name = el.dataset.el
if (name !== undefined) {
const elName = this.toElementName('el', name)
this[elName] = el
el.classList.add('el-' + name)
}
})
}// }}}
toElementName(prefix, str) {// {{{
str = prefix + '-' + str
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
}// }}}
connectedCallback() {// {{{
// Stupid stuff because of Preact.
this.appendChild(this.clonedNodes)
}// }}}
}

View file

@ -94,7 +94,7 @@ export class MarkedPosition {
constructor() {// {{{ constructor() {// {{{
window.marked_setpos = (event) => this.setpos(event) window.marked_setpos = (event) => this.setpos(event)
window.marked_changecheckbox = (event) => this.changecheckbox(event) window.marked_changecheckbox = (event) => this.changecheckbox(event)
window.marked_copy_to_clipboard = (event) => this.copy_to_clipboard(event) window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname)
this.render() this.render()
}// }}} }// }}}
setpos(event) {// {{{ setpos(event) {// {{{
@ -120,7 +120,7 @@ export class MarkedPosition {
} }
}) })
}// }}} }// }}}
async copy_to_clipboard(event) {// {{{ async copy_to_clipboard(event, tagname) {// {{{
if (!event.shiftKey) if (!event.shiftKey)
return return
@ -134,8 +134,11 @@ export class MarkedPosition {
const text = event.target.innerText const text = event.target.innerText
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
event.target.classList.add('copy')
setTimeout(()=>event.target.classList.remove('copy'), 250) const tagClasslist = event.target.closest(tagname).classList
tagClasslist.add('copy')
setTimeout(()=>tagClasslist.remove('copy'), 250)
} catch (err) { } catch (err) {
console.error('Failed to copy: ', err) console.error('Failed to copy: ', err)
alert('Failed to copy: ', err) alert('Failed to copy: ', err)
@ -143,7 +146,6 @@ export class MarkedPosition {
}// }}} }// }}}
render() {// {{{ render() {// {{{
const markedObject = this
this.marked = new Marked() this.marked = new Marked()
this.marked.use(markedTokenPosition()) this.marked.use(markedTokenPosition())
this.marked.use({ this.marked.use({
@ -188,12 +190,12 @@ export class MarkedPosition {
const code = token.text.replace(other.endingNewline, '') + '\n' const code = token.text.replace(other.endingNewline, '') + '\n'
if (!langString) { if (!langString) {
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>` return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code>`
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
+ '</code></pre>\n' + '</code></pre>\n'
} }
return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-` return `<pre ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'pre')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}"><code class="language-`
+ escapeHtmlEntities(langString) + escapeHtmlEntities(langString)
+ '">' + '">'
+ (token.escaped ? code : escapeHtmlEntities(code, true)) + (token.escaped ? code : escapeHtmlEntities(code, true))
@ -283,7 +285,7 @@ export class MarkedPosition {
}, },
codespan(token) { codespan(token) {
return `<code ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event)" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>` return `<code ondblclick="marked_setpos(event)" onmousedown="marked_copy_to_clipboard(event, 'code')" data-offset-start="${token.position.start.offset}" data-offset-end="${token.position.end.offset}">${escapeHtmlEntities(token.text, true)}</code>`
}, },
br(token) { br(token) {

View file

@ -0,0 +1,283 @@
import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
import { API } from './api.mjs'
export class N2PagePreferences extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.el-sets {
display: grid;
grid-template-columns: min-content;
grid-gap: 32px;
}
:host > div {
margin-bottom: 32px;
}
.dev-pref-set {
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 16px;
align-items: center;
white-space: nowrap;
}
</style>
<h1>Preferences</h1>
<div>Changes preferences to not download images or files on the device doesn't remove the already downloaded data.</div>
<div class="dev-pref-set">
<div>Device preference set</div>
<select data-el="dev-preference-set"></select>
</div>
<div data-el="sets"></div>
<button data-el="new-set">New set</button>
<button data-el="save" disabled>Save</button>
`
}// }}}
constructor() {// {{{
super(true)
this.sets = []
this.elNewSet.addEventListener('click', () => this.newSet())
this.elSave.addEventListener('click', () => this.save())
this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event))
window._mbus.subscribe('SHOW_PAGE', async event => {
if (event.detail.data?.page == 'preferences') {
this.sets = await this.getPreferenceSets()
this.render()
}
})
window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified())
window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set))
}// }}}
sortSets(a, b) {// {{{
if (a.name == 'default') return -1
if (b.name == 'default') return 1
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1
return 0
}// }}}
async render() {// {{{
try {
this.sets.sort(this.sortSets)
this.elSets.replaceChildren(...this.sets)
const setNames = this.sets.entries().map(([i, set]) => {
const optn = document.createElement('option')
optn.innerText = set.name
return optn
})
this.elDevPreferenceSet.replaceChildren(...setNames)
} catch (e) {
console.error(e)
alert(e.message)
}
}// }}}
async getPreferenceSets() {// {{{
const userData = localStorage.getItem('user')
if (userData === null)
throw new Error('Could not find user in localStorage')
const user = JSON.parse(userData)
const prefsData = user.Preferences
if (prefsData === undefined)
throw new Error('User object is missing preferences')
if (!prefsData.hasOwnProperty('default'))
throw new Error('The "default" preferences set is missing')
return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name]))
}// }}}
async retrieveServerPreferences() {// {{{
try {
API.query('GET', '/user/preferences')
} catch (e) {
console.error(e)
alert(`Error retrieving preferences: ${e.message}`)
}
}// }}}
changePreferenceSet(event) {// {{{
this.preferencesModified()
}// }}}
newSet() {// {{{
let name = prompt("Name for new preference set")
if (!name)
return
name = name.trim()
if (name === '')
return
if (name == 'default') {
alert(`Name can't be "default".`)
return
}
const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase())
if (exists) {
alert(`Set with name "${name}" already exist.`)
return
}
this.sets.push(new N2PreferenceSet(name, {}))
this.preferencesModified()
this.render()
}// }}}
preferencesModified() {// {{{
this.elSave.removeAttribute('disabled')
}// }}}
preferencesDelete(deleteSet) {// {{{
if (deleteSet.name == 'default') {
alert("Can't delete the default set.")
return
}
if (!confirm(`Confirm deleting "${deleteSet.name}"`))
return
this.sets = this.sets.filter(set => {
return !(set.name === deleteSet.name)
})
this.preferencesModified()
this.render()
}// }}}
async save() {// {{{
try {
let newPrefs = {}
this.sets.forEach(s => {
const setState = s.getState()
newPrefs[setState.name] = setState.state
})
// Throws exception on both HTTP and application errors.
await API.query('POST', '/user/preferences', newPrefs)
const userData = localStorage.getItem('user')
const user = JSON.parse(userData)
user.Preferences = newPrefs
localStorage.setItem('user', JSON.stringify(user))
localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value)
_mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED')
} catch (e) {
console.error(e)
alert(e.message)
} finally {
this.elSave.setAttribute('disabled', true)
}
}// }}}
}
customElements.define('n2-pagepreferences', N2PagePreferences)
// Preferences is a set of preferences, of which there can be many named.
export class N2PreferenceSet extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
:host {
border: 1px solid var(--line-color);
padding: 16px;
display: grid;
grid-template-columns: min-content 1fr;
justify-items: start;
align-items: center;
grid-gap: 8px 16px;
white-space: nowrap;
user-select: none;
.header {
grid-column: 1 / -1;
width: 100%;
display: grid;
grid-template-columns: 1fr min-content;
.el-name {
font-weight: bold;
margin-bottom: 32px;
cursor: pointer;
color: var(--color1);
}
.el-delete {
cursor: pointer;
}
}
}
</style>
<div class="header">
<div data-el="name"></div>
<div data-el="delete"></div>
</div>
<div><label for="download-images">Download images on device</label></div>
<input data-field="download-images" type="checkbox" id="download-images">
<div><label for="download-files">Download files on device</label></div>
<input data-field="download-files" type="checkbox" id="download-files">
`
}// }}}
constructor(name, data) {// {{{
super(true)
this.name = name
this.data = data
this.render()
// Enable the save button when settings are modified.
this.allFields().forEach(f =>
f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED'))
)
this.elName.addEventListener('click', () => this.updateName())
this.elDelete.addEventListener('click', () => this.deleteSet())
}// }}}
updateName() {// {{{
if (this.name == 'default') {
alert('Can not change name of the default profile.')
return
}
const name = prompt("Change name", this.name)
if (!name)
return
this.name = name
this.render()
_mbus.dispatch('PREFERENCE_SET_MODIFIED')
}// }}}
deleteSet() {// {{{
_mbus.dispatch('PREFERENCE_SET_DELETE', { set: this })
}// }}}
render() {// {{{
this.elName.innerText = this.name
this.fieldDownloadImages.checked = this.data.DownloadImages
this.fieldDownloadFiles.checked = this.data.DownloadFiles
}// }}}
getState() {// {{{
const name = this.name.trim()
if (name === '')
throw new Error('Name can not be empty.')
return {
name: this.name.trim(),
state: this.fieldValues(),
}
}// }}}
}
customElements.define('n2-preferenceset', N2PreferenceSet)

View file

@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement {
constructor() { constructor() {
super() super()
window._mbus.subscribe('SHOW_PAGE', () => this.render()) window._mbus.subscribe('SHOW_PAGE', event => {
if (event.detail.data?.page == 'storage')
this.render()
})
} }
async render() { async render() {
const countNodes = await globalThis.nodeStore.nodeCount() const countNodes = await globalThis.nodeStore.nodeCount()

View file

@ -128,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement {
this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search'))
this.elSync.addEventListener('click', () => _sync.run()) this.elSync.addEventListener('click', () => _sync.run())
this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false))
this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' }))
this.elHideTree.addEventListener('click', event => { this.elHideTree.addEventListener('click', event => {
event.stopPropagation() event.stopPropagation()
_mbus.dispatch('TREE_EXPANSION', { expand: false }) _mbus.dispatch('TREE_EXPANSION', { expand: false })
@ -197,9 +198,8 @@ export class N2Sidebar extends CustomHTMLElement {
this.expandedNodes[UUID] = false this.expandedNodes[UUID] = false
return this.expandedNodes[UUID] return this.expandedNodes[UUID]
}//}}} }//}}}
setNodeExpanded(node, value) {//{{{ async setNodeExpanded(node, value) {//{{{
let expanded = this.expandedNodes[node.UUID] let expanded = this.expandedNodes[node.UUID]
if (expanded === undefined) { if (expanded === undefined) {
this.expandedNodes[node.UUID] = false this.expandedNodes[node.UUID] = false
expanded = false expanded = false
@ -249,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement {
// Holding shift down does it recursively. // Holding shift down does it recursively.
case Space: case Space:
case 'Enter': case 'Enter':
if (n.UUID === ROOT_NODE)
return
const expanded = this.getNodeExpanded(n.UUID) const expanded = this.getNodeExpanded(n.UUID)
if (event.shiftKey) { if (event.shiftKey) {
this.recursiveExpand(n, !expanded) this.recursiveExpand(n, !expanded)
@ -259,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement {
} }
break break
case 'g':
case 'Home': case 'Home':
this.navigateTop() this.navigateTop()
break break
case 'G':
case 'End': case 'End':
this.navigateBottom() this.navigateBottom()
break break
case 'j':
case 'ArrowDown': case 'ArrowDown':
await this.navigateDown(this.selectedNode) await this.navigateDown(this.selectedNode)
break break
case 'k':
case 'ArrowUp': case 'ArrowUp':
await this.navigateUp(this.selectedNode) await this.navigateUp(this.selectedNode)
break break
case 'h':
case 'ArrowLeft': case 'ArrowLeft':
await this.navigateLeft(this.selectedNode) await this.navigateLeft(this.selectedNode)
break break
case 'l':
case 'ArrowRight': case 'ArrowRight':
await this.navigateRight(this.selectedNode) await this.navigateRight(this.selectedNode)
break break
default: default:
// nonsole.log(event.key)
handled = false handled = false
} }
@ -412,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement {
}//}}} }//}}}
async navigateTop() {//{{{ async navigateTop() {//{{{
const root = await nodeStore.get(ROOT_NODE) const root = await nodeStore.get(ROOT_NODE)
if (root.Children.length === 0) _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true })
return
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true })
}//}}} }//}}}
async navigateBottom() {//{{{ async navigateBottom() {//{{{
const root = await nodeStore.get(ROOT_NODE) const orphaned = await nodeStore.get(ORPHANED_NODE)
if (root.Children.length === 0)
return
const toplevel = root.Children[root.Children.length - 1] if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) {
_mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true })
return
}
/* TODO - fix this when orphaned nodes are implemented.
const toplevel = orphaned.Children[orphaned.Children.length - 1]
const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID)
if (toplevelExpanded) { if (toplevelExpanded) {
const lastnode = this.getLastExpandedNode(toplevel) const lastnode = this.getLastExpandedNode(toplevel)
_mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true })
} else } else
_mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true })
*/
}//}}} }//}}}
getParentWithNextSibling(node) {//{{{ getParentWithNextSibling(node) {//{{{
@ -449,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement {
if (state) if (state)
await this.setNodeExpanded(node, true) await this.setNodeExpanded(node, true)
// An expanded node needs to have its children fetched.
if (!node.hasFetchedChildren())
await node.fetchChildren()
for (const child of node.Children) for (const child of node.Children)
await this.recursiveExpand(child, state) await this.recursiveExpand(child, state)
@ -577,7 +575,12 @@ export class N2TreeNode extends CustomHTMLElement {
this.rendered = false this.rendered = false
this.dragNode = null this.dragNode = null
this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) this.elExpandToggle.addEventListener('click', event => {
if (this.node.hasChildren())
this.expandNode(event)
else
_mbus.dispatch('TREE_NODE_SELECTED', this.node)
})
this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node))
_mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => {
@ -592,6 +595,7 @@ export class N2TreeNode extends CustomHTMLElement {
this.elName.addEventListener('dragenter', event => this.dragEnter(event)) this.elName.addEventListener('dragenter', event => this.dragEnter(event))
this.elName.addEventListener('dragleave', event => this.dragLeave(event)) this.elName.addEventListener('dragleave', event => this.dragLeave(event))
}// }}} }// }}}
dragStart(e) {// {{{ dragStart(e) {// {{{
if (this.node.isModified()) { if (this.node.isModified()) {
alert('Save note before moving it.') alert('Save note before moving it.')
@ -654,6 +658,16 @@ export class N2TreeNode extends CustomHTMLElement {
_app.dragIcon.icon('') _app.dragIcon.icon('')
this.classList.remove('drag-target') this.classList.remove('drag-target')
}// }}} }// }}}
async expandNode(event) {// {{{
const expanded = _app.sidebar.getNodeExpanded(this.node.UUID)
if (event.shiftKey) {
_app.sidebar.recursiveExpand(this.node, !expanded)
} else {
_app.sidebar.setNodeExpanded(this.node, !expanded)
}
}// }}}
async fetchChildren(force_fetch) {//{{{ async fetchChildren(force_fetch) {//{{{
if (this.children_populated && !force_fetch) if (this.children_populated && !force_fetch)
return return

View file

@ -90,6 +90,7 @@ export class Sync {
nodeStore.setAppState('latest_sync_node', currMax) nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) { } catch (e) {
console.error('sync node tree', e) console.error('sync node tree', e)
alert(e.message)
} finally { } finally {
syncEnd = Date.now() syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000 const duration = (syncEnd - syncStart) / 1000
@ -157,8 +158,8 @@ export class Sync {
_mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length })
} catch (e) { } catch (e) {
console.trace(e) console.error(e)
alert(e.error) alert(e.message)
return return
} }
} }

27
user.go
View file

@ -1,27 +0,0 @@
package main
import (
// External
"github.com/golang-jwt/jwt/v5"
)
type UserSession struct {
UserID int
Username string
Password string
Name string
ClientUUID string
}
func NewUser(claims jwt.MapClaims) (u UserSession) {
uid, _ := claims["uid"].(float64)
name, _ := claims["name"].(string)
username, _ := claims["login"].(string)
clientUUID, _ := claims["cid"].(string)
u.UserID = int(uid)
u.Username = username
u.Name = name
u.ClientUUID = clientUUID
return
}

63
user/pkg.go Normal file
View file

@ -0,0 +1,63 @@
package user
import (
// External
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
// Standard
"encoding/json"
)
type User struct {
ID int
Username string
Name string
Preferences map[string]UserPreferences
}
type UserSession struct {
UserID int
Username string
Password string
Name string
ClientUUID string
Db *sqlx.DB
}
type UserPreferences struct {
DownloadImages bool
DownloadFiles bool
}
func NewUser(claims jwt.MapClaims) (u UserSession) {
uid, _ := claims["uid"].(float64)
name, _ := claims["name"].(string)
username, _ := claims["login"].(string)
clientUUID, _ := claims["cid"].(string)
u.UserID = int(uid)
u.Username = username
u.Name = name
u.ClientUUID = clientUUID
return
}
func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) {
row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID)
var data []byte
err = row.Scan(&data)
if err != nil {
return
}
err = json.Unmarshal(data, &prefs)
return
}
func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) {
j, _ := json.Marshal(prefs)
_, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j)
return
}

View file

@ -32,9 +32,10 @@
</div> </div>
<!-- History --> <!-- History -->
<div id="page-history"> <n2-pagehistory id="page-history"></n2-pagehistory>
<n2-pagehistory></n2-pagehistory>
</div> <!-- Preferences -->
<n2-pagepreferences id="page-preferences"></n2-pagepreferences>
</div> </div>
<n2-syncprogress></n2-syncprogress> <n2-syncprogress></n2-syncprogress>
@ -46,6 +47,7 @@
import {App} from "/js/{{ .VERSION }}/app.mjs" import {App} from "/js/{{ .VERSION }}/app.mjs"
import {API} from 'api' import {API} from 'api'
import {Sync} from 'sync' import {Sync} from 'sync'
import { } from '/js/{{ .VERSION }}/page_preferences.mjs'
import { } from '/js/{{ .VERSION }}/page_storage.mjs' import { } from '/js/{{ .VERSION }}/page_storage.mjs'
import { } from '/js/{{ .VERSION }}/page_history.mjs' import { } from '/js/{{ .VERSION }}/page_history.mjs'
import { } from '/js/{{ .VERSION }}/file.mjs' import { } from '/js/{{ .VERSION }}/file.mjs'