From d6d8b64bb93fafb89c27850c22dc5fa26d1576a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 09:59:01 +0200 Subject: [PATCH 1/7] Fixed recursive open of nodes, bumped to v29 --- main.go | 2 +- static/js/sidebar.mjs | 28 ++++++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 5d8bf68..213d1ed 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,7 @@ import ( "text/template" ) -const VERSION = "v28" +const VERSION = "v29" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 129b9d1..9c2b8c8 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -197,9 +197,8 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} - setNodeExpanded(node, value) {//{{{ + async setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] - if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false @@ -446,14 +445,20 @@ export class N2Sidebar extends CustomHTMLElement { return currNode }//}}} async recursiveExpand(node, state) {//{{{ + if (!state) { + await this.setNodeExpanded(node, false) + return + } + if (state) 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) await this.recursiveExpand(child, state) - - if (!state) - await this.setNodeExpanded(node, false) }//}}} async makeVisible(node, providedAncestors, dontExpand) {// {{{ const treenode = this.treeNodeComponents[node.UUID] @@ -577,7 +582,7 @@ export class N2TreeNode extends CustomHTMLElement { this.rendered = false this.dragNode = null - this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) + this.elExpandToggle.addEventListener('click', event => this.expandNode(event)) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { @@ -592,6 +597,7 @@ export class N2TreeNode extends CustomHTMLElement { this.elName.addEventListener('dragenter', event => this.dragEnter(event)) this.elName.addEventListener('dragleave', event => this.dragLeave(event)) }// }}} + dragStart(e) {// {{{ if (this.node.isModified()) { alert('Save note before moving it.') @@ -654,6 +660,16 @@ export class N2TreeNode extends CustomHTMLElement { _app.dragIcon.icon('') 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) {//{{{ if (this.children_populated && !force_fetch) return From 0fe5cd78b3ccd25f1a1822743947398a1e496afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 10:08:36 +0200 Subject: [PATCH 2/7] Removed vi keybindings to enable search at a later date. --- static/js/sidebar.mjs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 9c2b8c8..227ff82 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -258,38 +258,31 @@ export class N2Sidebar extends CustomHTMLElement { } break - case 'g': case 'Home': this.navigateTop() break - case 'G': case 'End': this.navigateBottom() break - case 'j': case 'ArrowDown': await this.navigateDown(this.selectedNode) break - case 'k': case 'ArrowUp': await this.navigateUp(this.selectedNode) break - case 'h': case 'ArrowLeft': await this.navigateLeft(this.selectedNode) break - case 'l': case 'ArrowRight': await this.navigateRight(this.selectedNode) break default: - // nonsole.log(event.key) handled = false } From ea3bdaca03f70741d046af6a04a50d7fba92dc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 10:10:16 +0200 Subject: [PATCH 3/7] Select leaf nodes on icon --- static/js/sidebar.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 227ff82..63c2b75 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -575,7 +575,12 @@ export class N2TreeNode extends CustomHTMLElement { this.rendered = false this.dragNode = null - this.elExpandToggle.addEventListener('click', event => this.expandNode(event)) + 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)) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { From f5cbfb0b220039e1ab1489781cff5177fa37de76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 10:12:48 +0200 Subject: [PATCH 4/7] Fixed regression of recursive expansion toggle --- static/js/sidebar.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 63c2b75..71a9371 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -438,11 +438,6 @@ export class N2Sidebar extends CustomHTMLElement { return currNode }//}}} async recursiveExpand(node, state) {//{{{ - if (!state) { - await this.setNodeExpanded(node, false) - return - } - if (state) await this.setNodeExpanded(node, true) @@ -452,6 +447,9 @@ export class N2Sidebar extends CustomHTMLElement { for (const child of node.Children) await this.recursiveExpand(child, state) + + if (!state) + await this.setNodeExpanded(node, false) }//}}} async makeVisible(node, providedAncestors, dontExpand) {// {{{ const treenode = this.treeNodeComponents[node.UUID] From 1a712fb7a914eb578d699fb892b8ad62e0e1c54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 10:20:15 +0200 Subject: [PATCH 5/7] Fix home/end to account for special pages --- static/js/sidebar.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 71a9371..07e8678 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -248,8 +248,6 @@ export class N2Sidebar extends CustomHTMLElement { // Holding shift down does it recursively. case Space: case 'Enter': - if (n.UUID === ROOT_NODE) - return const expanded = this.getNodeExpanded(n.UUID) if (event.shiftKey) { this.recursiveExpand(n, !expanded) @@ -404,23 +402,26 @@ export class N2Sidebar extends CustomHTMLElement { }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return + const orphaned = await nodeStore.get(ORPHANED_NODE) - 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) if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) } 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) {//{{{ From 81d02b82dc30c59c5523792f09a0950e5c6ad702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 18 Jun 2026 09:21:23 +0200 Subject: [PATCH 6/7] Small refactor for user preferences --- authentication/pkg.go | 27 +++++++++------ main.go | 27 ++++++++++++--- sql/00010.sql | 1 + static/css/notes2.css | 18 ++++++---- static/images/icon_menu.svg | 61 ++++++++++++++++++---------------- static/js/api.mjs | 45 ++++++++++--------------- static/js/page_preferences.mjs | 32 ++++++++++++++++++ static/js/page_storage.mjs | 5 ++- static/js/sidebar.mjs | 1 + static/js/sync.mjs | 5 +-- user.go | 27 --------------- user/pkg.go | 57 +++++++++++++++++++++++++++++++ views/pages/notes2.gotmpl | 8 +++-- 13 files changed, 202 insertions(+), 112 deletions(-) create mode 100644 sql/00010.sql create mode 100644 static/js/page_preferences.mjs delete mode 100644 user.go create mode 100644 user/pkg.go diff --git a/authentication/pkg.go b/authentication/pkg.go index 9eb6245..c0b9a2e 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -8,6 +8,9 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + // Internal + appUser "notes2/user" + // Standard "database/sql" "encoding/hex" @@ -27,12 +30,6 @@ type Manager struct { ExpireDays int } -type User struct { - ID int - Username string - Name string -} - func httpError(w http.ResponseWriter, err error) { // {{{ j, _ := json.Marshal(struct { 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") j, _ := json.Marshal(struct { OK bool - User User + User appUser.User Token string }{true, user, token}) 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 row = mngr.db.QueryRow(` - SELECT id, username, name + SELECT id, username, name, preferences FROM public.user WHERE LOWER(username) = LOWER($1) AND @@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool username, 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" { err = nil authenticated = false return } if err != nil { + authenticated = false + return + } + + err = json.Unmarshal(data, &user.Preferences) + if err != nil { + authenticated = false return } @@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin changed = (rowsAffected == 1) 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. // Loop through until a unique one is established. var proposedClientUUID string diff --git a/main.go b/main.go index 213d1ed..86e52e9 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Internal "notes2/authentication" "notes2/html_template" + appUser "notes2/user" "os" // Standard @@ -134,6 +135,7 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) + http.HandleFunc("/user/preferences", authenticated(actionUserPreferences)) http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) @@ -178,7 +180,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon } // 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)) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID) @@ -360,8 +362,8 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ } responseData(w, map[string]any{ - "OK": true, - "Count": count, + "OK": true, + "Count": count, }) } // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ @@ -389,6 +391,20 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} +func actionUserPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUser(r) + prefs, err := user.Preferences() + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Preferences": prefs, + }) +} // }}} + func createNewUser(username string) { // {{{ reader := bufio.NewReader(os.Stdin) @@ -431,7 +447,8 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(UserSession) +func getUser(r *http.Request) appUser.UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) + user.Db = db return user } // }}} diff --git a/sql/00010.sql b/sql/00010.sql new file mode 100644 index 0000000..ecd8ab4 --- /dev/null +++ b/sql/00010.sql @@ -0,0 +1 @@ +ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/notes2.css b/static/css/notes2.css index a48a319..f8d46eb 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -73,9 +73,10 @@ button { 1fr; } - &.page-history { + /* The other pages just gets the whole page without dividing it up. */ + &:not(.page-node) { grid-template-areas: - "tree-expander tree pad1 n2-pagehistory pad2" + "tree-expander tree pad1 n2-page pad2" ; grid-template-columns: @@ -245,7 +246,6 @@ button { #notes2 { &.page-node { - #page-root { display: none; } @@ -260,7 +260,7 @@ button { display: contents; n2-pagestorage { - grid-area: content; + grid-area: n2-page; } } } @@ -268,9 +268,14 @@ button { &.page-history { #page-history { display: grid; - grid-area: n2-pagehistory; + grid-area: n2-page; + } + } - n2-pagehistory {} + &.page-preferences { + #page-preferences { + display: grid !important; + grid-area: n2-page; } } @@ -282,7 +287,6 @@ button { #page-root { display: contents !important; } - } } diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg index e60bdee..cfdd1e8 100644 --- a/static/images/icon_menu.svg +++ b/static/images/icon_menu.svg @@ -2,18 +2,18 @@ - - - - menu - hamburger - - - + transform="translate(-147.15925,-92.339586)">menuhamburger diff --git a/static/js/api.mjs b/static/js/api.mjs index 3fff10a..26a19de 100644 --- a/static/js/api.mjs +++ b/static/js/api.mjs @@ -1,7 +1,7 @@ export class API { // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. static async query(method, path, request) { - return new Promise((resolve, reject) => { + try { const body = JSON.stringify(request) const headers = {} @@ -12,33 +12,22 @@ export class API { headers.Authorization = `Bearer ${token}` } - fetch(path, { method, headers, body }) - .then(response => { - // An HTTP communication level error occured. - if (!response.ok || response.status != 200) - return reject({ - type: 'http', - error: response, - }) - return response.json() - }) - .then(json => { - // Application level response are handled here. - if (!json.OK) - return reject({ - type: 'application', - error: json.Error, - application: json, - }) - resolve(json) - }) - .catch(err => - // Catch any other errors from fetch. - reject({ - type: 'http', - error: err, - })) - }) + const res = await fetch(path, { method, headers, body }) + // An HTTP communication level error occured. + if (!res.ok || res.status != 200) + throw new Error('HTTP error', { cause: { type: 'http', error: res, }}) + + // Application level response are handled here. + const json = await res.json() + if (!json.OK) + throw new Error(json.Error, { cause: { type: 'application', application: json, }}) + + return json + + } catch (err) { + // Catch any other errors from fetch. + throw new Error(err.message, { cause: { type: 'http', error: err, }}) + } } static hasAuthenticationToken() {//{{{ diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs new file mode 100644 index 0000000..273523c --- /dev/null +++ b/static/js/page_preferences.mjs @@ -0,0 +1,32 @@ +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 = ` +

Preferences

+ ` + }// }}} + constructor() {// {{{ + super() + window._mbus.subscribe('SHOW_PAGE', event => { + if (event.detail.data?.page == 'preferences') + this.render() + }) + }// }}} + async render() {// {{{ + }// }}} + getPreferences() { + API.query('GET', '/user/preferences') + } +} +customElements.define('n2-pagepreferences', N2PagePreferences) + +// Preferences is a set of preferences, of which there can be many named. +class Preferences { + constructor(name, data) { + this.name = name + this.data = data + } +} diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs index 931a718..a007130 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement { constructor() { super() - window._mbus.subscribe('SHOW_PAGE', () => this.render()) + window._mbus.subscribe('SHOW_PAGE', event => { + if (event.detail.data?.page == 'storage') + this.render() + }) } async render() { const countNodes = await globalThis.nodeStore.nodeCount() diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 07e8678..6cd5814 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -128,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement { this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSync.addEventListener('click', () => _sync.run()) 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 => { event.stopPropagation() _mbus.dispatch('TREE_EXPANSION', { expand: false }) diff --git a/static/js/sync.mjs b/static/js/sync.mjs index fe72c3f..daa603f 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -90,6 +90,7 @@ export class Sync { nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { console.error('sync node tree', e) + alert(e.message) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -157,8 +158,8 @@ export class Sync { _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { - console.trace(e) - alert(e.error) + console.error(e) + alert(e.message) return } } diff --git a/user.go b/user.go deleted file mode 100644 index b1c2abf..0000000 --- a/user.go +++ /dev/null @@ -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 -} diff --git a/user/pkg.go b/user/pkg.go new file mode 100644 index 0000000..4810df0 --- /dev/null +++ b/user/pkg.go @@ -0,0 +1,57 @@ +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 +} diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index d54c71d..2755aea 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -32,9 +32,10 @@ -
- -
+ + + + @@ -46,6 +47,7 @@ import {App} from "/js/{{ .VERSION }}/app.mjs" import {API} from 'api' import {Sync} from 'sync' + import { } from '/js/{{ .VERSION }}/page_preferences.mjs' import { } from '/js/{{ .VERSION }}/page_storage.mjs' import { } from '/js/{{ .VERSION }}/page_history.mjs' import { } from '/js/{{ .VERSION }}/file.mjs' From 74851b9c4d31d124a7d23be11c4b479d3df9ac1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 21 Jun 2026 10:59:38 +0200 Subject: [PATCH 7/7] Prefs managing mostly done --- main.go | 48 ++++- static/css/notes2.css | 2 +- static/js/app.mjs | 14 ++ static/js/lib/custom_html_element.mjs | 56 +++--- static/js/page_preferences.mjs | 269 +++++++++++++++++++++++++- user/pkg.go | 6 + 6 files changed, 344 insertions(+), 51 deletions(-) diff --git a/main.go b/main.go index 86e52e9..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -135,7 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/user/preferences", authenticated(actionUserPreferences)) + 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/{sequence}/{offset}", authenticated(actionSyncFromServer)) @@ -268,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) offset, _ := strconv.Atoi(r.PathValue("offset")) @@ -291,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) @@ -311,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -327,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -350,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -367,7 +368,7 @@ func actionNodeHistoryCount(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) var request struct { @@ -391,8 +392,8 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} -func actionUserPreferences(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) +func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUserSession(r) prefs, err := user.Preferences() if err != nil { httpError(w, err) @@ -404,6 +405,33 @@ func actionUserPreferences(w http.ResponseWriter, r *http.Request) { // {{{ "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) { // {{{ reader := bufio.NewReader(os.Stdin) @@ -447,7 +475,7 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) appUser.UserSession { // {{{ +func getUserSession(r *http.Request) appUser.UserSession { // {{{ user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) user.Db = db return user diff --git a/static/css/notes2.css b/static/css/notes2.css index f8d46eb..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -274,7 +274,7 @@ button { &.page-preferences { #page-preferences { - display: grid !important; + display: block; grid-area: n2-page; } } diff --git a/static/js/app.mjs b/static/js/app.mjs index c556341..90bad39 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -2,6 +2,7 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { N2Sidebar } from 'sidebar' import { Node } from 'node' +import { N2PreferenceSet } from './page_preferences.mjs' export class App { static PAGES = ['node', 'history', 'storage'] @@ -14,6 +15,8 @@ export class App { this.nodeUI = document.getElementById('note') this.dragIcon = new N2DragIcon() + this.preferences = this.getPreferences() + this.sidebar.render().then(sidebar => { document.getElementById('tree').append(sidebar) document.getElementById('tree-nodes')?.focus() @@ -61,6 +64,11 @@ export class App { 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('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { @@ -211,6 +219,12 @@ export class App { let classList = document.querySelector('#main-page').classList 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 { diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index 2cec808..d1fb7ae 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -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 { constructor(useShadow) {// {{{ super() + this._fields = new Map() + const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.querySelectorAll('*').forEach(el => { @@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement { if (field !== undefined) { const fieldName = this.toElementName('field', field) this[fieldName] = el + this._fields.set(this.toElementName('', field), 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) {// {{{ str = prefix + '-' + str 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) - }// }}} -} diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs index 273523c..9655278 100644 --- a/static/js/page_preferences.mjs +++ b/static/js/page_preferences.mjs @@ -5,28 +5,279 @@ export class N2PagePreferences extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` +

Preferences

+ +
Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
+ +
+
Device preference set
+ +
+ +
+ + + ` }// }}} constructor() {// {{{ - super() - window._mbus.subscribe('SHOW_PAGE', event => { - if (event.detail.data?.page == 'preferences') + 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) + } + }// }}} - getPreferences() { - API.query('GET', '/user/preferences') - } } customElements.define('n2-pagepreferences', N2PagePreferences) // Preferences is a set of preferences, of which there can be many named. -class Preferences { - constructor(name, data) { +export class N2PreferenceSet extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + +
+
+
+
+ +
+ + +
+ + ` + }// }}} + 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) diff --git a/user/pkg.go b/user/pkg.go index 4810df0..bcdfac8 100644 --- a/user/pkg.go +++ b/user/pkg.go @@ -55,3 +55,9 @@ func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) 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 +}