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 550b381..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Internal "notes2/authentication" "notes2/html_template" + appUser "notes2/user" "os" // Standard @@ -23,7 +24,7 @@ import ( "text/template" ) -const VERSION = "v24" +const VERSION = "v29" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -134,6 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) 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/{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 := 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) @@ -266,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")) @@ -289,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) @@ -309,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") @@ -325,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") @@ -348,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") @@ -360,12 +363,12 @@ 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) { // {{{ - user := getUser(r) + user := getUserSession(r) body, _ := io.ReadAll(r.Body) 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) { // {{{ reader := bufio.NewReader(os.Stdin) @@ -431,7 +475,8 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(UserSession) +func getUserSession(r *http.Request) appUser.UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) + user.Db = db return user } // }}} diff --git a/node.go b/node.go index e3a61d5..a25c771 100644 --- a/node.go +++ b/node.go @@ -54,6 +54,7 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` + Special bool } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -135,6 +136,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, FROM public.node WHERE + NOT special AND user_id = $1 AND client != $5::uuid AND ( diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..2701ba5 --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1,123 @@ +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + -- Safeguard against being your own parent. + IF node_uuid = node_parent_uuid THEN + RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT'; + END IF; + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + parent_uuid = node_parent_uuid, + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/sql/00009.sql b/sql/00009.sql new file mode 100644 index 0000000..50487f3 --- /dev/null +++ b/sql/00009.sql @@ -0,0 +1,35 @@ +-- Special node such as orphaned and deleted nodes. +ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL; + + +-- Needs to be dropped in order to drop the index on UUID. +ALTER TABLE public.node DROP CONSTRAINT node_node_fk; + +-- Index was missing user ID. +DROP INDEX public.node_uuid_idx; +CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid"); + +-- Restore the "foreign" key of parent UUID back to UUID. +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT; + + +-- Auto-create the special nodes for each user. +CREATE OR REPLACE FUNCTION create_user_nodes() +RETURNS TRIGGER AS $$ +BEGIN + -- NEW holds the row being created. + -- No semi-colons omitted here, PL/pgSQL requires them. + INSERT INTO public.node (user_id, uuid, parent_uuid, special, name) + VALUES + (NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'), + (NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'), + (NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_after_user_insert +AFTER INSERT ON public.user +FOR EACH ROW +EXECUTE FUNCTION create_user_nodes(); 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/markdown.css b/static/css/markdown.css index 1ecbc94..832d4a2 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -102,6 +102,11 @@ border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px; + + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + } } pre { @@ -111,6 +116,14 @@ border-radius: 4px; white-space: pre-wrap; + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + code { + background-color: inherit !important; + } + } + code { border: unset; padding: unset; diff --git a/static/css/notes2.css b/static/css/notes2.css index dfe4156..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,15 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 216px; + --functions-width: 150px; + + --menu-color: #fff; + --menu-item-hover-color: #f4f4f4; + + --font-monospace: "Liberation Mono", monospace; + + --markdown-copy-border: 1px solid #0a0; + --markdown-copy-background: #e3f4d7; } html { @@ -20,6 +28,10 @@ html { filter: var(--colorize); } +textarea { + font-family: var(--font-monospace); +} + button { font-size: 1em; padding: 4px 8px; @@ -61,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: @@ -186,6 +199,11 @@ button { img { width: auto; height: 18px; + + &.deleted { + height: 24px; + transform: translateX(3px) translateY(3px); + } } } @@ -228,7 +246,6 @@ button { #notes2 { &.page-node { - #page-root { display: none; } @@ -243,7 +260,7 @@ button { display: contents; n2-pagestorage { - grid-area: content; + grid-area: n2-page; } } } @@ -251,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: block; + grid-area: n2-page; } } @@ -265,7 +287,6 @@ button { #page-root { display: contents !important; } - } } @@ -433,7 +454,6 @@ n2-nodeui { grid-area: content; justify-self: center; word-wrap: break-word; - font-family: monospace; font-size: 1em; color: #333; @@ -457,6 +477,10 @@ n2-nodeui { grid-area: content; display: none; + font-family: var(--font-monospace); + font-size: 1em; + font-weight: 400; + border-top: 1px solid #e0e0e0; margin-top: 8px; margin-bottom: 32px; 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 @@ + transform="translate(-147.15925,-92.339586)">
`
+ return ``
+ (token.escaped ? code : escapeHtmlEntities(code, true))
+ '
\n'
}
- return `'
+ (token.escaped ? code : escapeHtmlEntities(code, true))
@@ -260,7 +285,7 @@ export class MarkedPosition {
},
codespan(token) {
- return `${escapeHtmlEntities(token.text, true)}`
+ return `${escapeHtmlEntities(token.text, true)}`
},
br(token) {
diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs
index 324f004..6be8f82 100644
--- a/static/js/node_store.mjs
+++ b/static/js/node_store.mjs
@@ -1,6 +1,8 @@
import { Node } from 'node'
export const ROOT_NODE = '00000000-0000-0000-0000-000000000000'
+export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001'
+export const DELETED_NODE = '00000000-0000-0000-0000-000000000002'
export class NodeStore {
constructor() {//{{{
@@ -13,6 +15,8 @@ export class NodeStore {
this.sendQueue = null
this.nodesHistory = null
this.files = null
+
+ this.initializeSpecialNodes()
}//}}}
initializeDB() {//{{{
return new Promise((resolve, reject) => {
@@ -76,8 +80,7 @@ export class NodeStore {
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
this.files = new SimpleNodeStore(this.db, 'files')
- this.initializeRootNode()
- .then(() => resolve())
+ resolve()
}
req.onerror = (event) => {
@@ -85,40 +88,11 @@ export class NodeStore {
}
})
}//}}}
- initializeRootNode() {//{{{
- return new Promise((resolve, reject) => {
- // The root node is a magical node which displays as the first node if none is specified.
- // If not already existing, it will be created.
- const trx = this.db.transaction('nodes', 'readwrite')
- const nodes = trx.objectStore('nodes')
- const getRequest = nodes.get(ROOT_NODE)
- getRequest.onsuccess = (event) => {
- // Root node exists - nice!
- if (event.target.result !== undefined) {
- resolve(event.target.result)
- return
- }
-
- const putRequest = nodes.put({
- UUID: ROOT_NODE,
- Name: 'Notes2',
- Content: 'Hello, World!',
- Updated: new Date().toISOString(),
- ParentUUID: '',
- })
- putRequest.onsuccess = (event) => {
- resolve(event.target.result)
- }
- putRequest.onerror = (event) => {
- reject(event.target.error)
- }
- }
- getRequest.onerror = (event) => reject(event.target.error)
- })
- }//}}}
- purgeCache() {//{{{
- this.nodes = {}
- }//}}}
+ initializeSpecialNodes() {// {{{
+ this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1)
+ this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1)
+ this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1)
+ }// }}}
node(uuid, dataIfUndefined, newLevel) {//{{{
let n = this.nodes[uuid]
@@ -272,6 +246,14 @@ export class NodeStore {
}//}}}
get(uuid, suppliedNodestore) {//{{{
return new Promise((resolve, reject) => {
+ switch (uuid) {
+ case ROOT_NODE:
+ case DELETED_NODE:
+ case ORPHANED_NODE:
+ resolve(this.nodes[uuid])
+ return
+ }
+
// A nodestore can be provided in order to
// avoid creating new transactions.
let trx
@@ -309,6 +291,16 @@ export class NodeStore {
return
}
+ if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) {
+ resolve(accumulated)
+ return
+ }
+
+ if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) {
+ resolve(accumulated)
+ return
+ }
+
const getRequest = nodeParentIndex.get(node.ParentUUID)
getRequest.onsuccess = (event) => {
// Node not found in IndexedDB.
diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs
index f6aa681..2106ada 100644
--- a/static/js/page_node.mjs
+++ b/static/js/page_node.mjs
@@ -2,14 +2,70 @@ import { ROOT_NODE, uuidv7 } from 'node_store'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { MarkedPosition } from './marked_position.mjs'
+class N2NodeMenu extends CustomHTMLElement {
+ static {// {{{
+ this.tmpl = document.createElement('template')
+ this.tmpl.innerHTML = `
+
+
+ `
+ }// }}}
+ constructor() {// {{{
+ super()
+ }// }}}
+}
+customElements.define('n2-nodemenu', N2NodeMenu)
+
export class N2PageNodeUI extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
@@ -28,11 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
-
-
-
+
+
+
`
}// }}}
@@ -41,12 +107,14 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node = null
this.style.display = 'contents'
- this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.marked = new MarkedPosition()
_mbus.subscribe('NODE_UI_OPEN', event => {
this.node = event.detail.data
- this.showMarkdown(true)
+
+
+ if (!this.node.isSpecial())
+ this.showMarkdown(true)
this.render()
})
@@ -67,11 +135,27 @@ export class N2PageNodeUI extends CustomHTMLElement {
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
_mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data))
+ // Binding the node rename handler.
this.elName.addEventListener('click', async () => this.renameNode())
+
+ // Bind handlers for content keyboard input and paste.
this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
+
+ // Bind node icon handlers.
+ this.elIconSave.addEventListener('click', () => this.saveNode())
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
- this.elIconTableFormat.addEventListener('click', event => {
+ this.elIconNewDocument.addEventListener('click', event => {
+ if (event.shiftKey)
+ _app.createNode(this.node.ParentUUID)
+ else
+ _app.createNode()
+ })
+
+ // Bind node menu items to handlers.
+ this.elNodeMenu.elFormatTables.addEventListener('click', event => {
+ this.elNodeMenu.hidePopover()
+
if (!event.shiftKey)
this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value)
else {
@@ -85,15 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value)
})
- this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' }))
- this.elIconSave.addEventListener('click', () => this.saveNode())
- this.elIconNewDocument.addEventListener('click', event => {
- if (event.shiftKey)
- _app.createNode(this.node.ParentUUID)
- else
- _app.createNode()
+ this.elNodeMenu.elHistory.addEventListener('click', () => {
+ _mbus.dispatch('SHOW_PAGE', { page: 'history' })
})
+ // Default is to always show markdown.
+ this.classList.add('show-markdown') // TODO Should probably be moved to settings.
this.showMarkdown(true)
}// }}}
renderName() {// {{{
@@ -306,7 +387,7 @@ export class N2PageNodeUI extends CustomHTMLElement {
// Node is modified with the new value. User has to save manually, otherwise other changes could be saved
// when a save wasn't expected.
- const newValue =`[${checkbox.checked ? 'x' : ' '}] `
+ const newValue = `[${checkbox.checked ? 'x' : ' '}] `
const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end)
this.node.setContent(modifiedContent)
@@ -424,6 +505,9 @@ export class Node {
return this._parent
}//}}}
moveToParent(newParentUUID) {// {{{
+ if (this.UUID === newParentUUID)
+ throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.")
+
this.ParentUUID = newParentUUID
this.data.ParentUUID = newParentUUID
this._modified = true
@@ -434,6 +518,9 @@ export class Node {
isFirstSibling() {//{{{
return this._sibling_before === null
}//}}}
+ isSpecial() {// {{{
+ return this.data.Special
+ }// }}}
content() {//{{{
/* TODO - implement crypto
if (this.CryptoKeyID != 0 && !this._decrypted)
@@ -497,23 +584,10 @@ export class Node {
console.log('waiting')
await Promise.all([history, sendQueue, nodeStoreAdding])
console.log('waiting done')
-
+
return
}//}}}
}
-class N2Menu extends CustomHTMLElement {
- static {
- this.tmpl = document.createElement('template')
- this.tmpl.innerHTML = `
-
- `
- }
-
- constructor() {
- super()
- }
-}
-customElements.define('n2-menu', N2Menu)
// vim: foldmethod=marker
diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs
new file mode 100644
index 0000000..9655278
--- /dev/null
+++ b/static/js/page_preferences.mjs
@@ -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 = `
+
+ Preferences
+
+ Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
+
+
+ Device preference set
+
+
+
+
+
+
+
+ `
+ }// }}}
+ 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 = `
+
+
+
+
+ ✘
+
+
+
+
+
+
+
+ `
+ }// }}}
+ 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/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 63c4ddc..6cd5814 100644
--- a/static/js/sidebar.mjs
+++ b/static/js/sidebar.mjs
@@ -1,4 +1,5 @@
-import { ROOT_NODE } from 'node_store'
+import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store'
+import { Node } from 'node'
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Color, Solver } from './lib/css_colorize.mjs'
@@ -127,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 })
@@ -156,8 +158,26 @@ export class N2Sidebar extends CustomHTMLElement {
this.expandedNodes[ROOT_NODE] = true
const startnode = await nodeStore.get(ROOT_NODE)
const starttreenode = new N2TreeNode(this, startnode, null)
+
+ const deletednode = await nodeStore.get(DELETED_NODE)
+ const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null)
+
+ const orphanednode = await nodeStore.get(ORPHANED_NODE)
+ const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null)
+
+ startnode._sibling_after = deletednode
+ deletednode._sibling_before = startnode
+
+ deletednode._sibling_after = orphanednode
+ orphanednode._sibling_before = deletednode
+
this.treeNodeComponents[startnode.UUID] = starttreenode
+ this.treeNodeComponents[deletednode.UUID] = deletedtreenode
+ this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode
+
this.elTreenodes.appendChild(await starttreenode.render())
+ this.elTreenodes.appendChild(await deletedtreenode.render())
+ this.elTreenodes.appendChild(await orphanedtreenode.render())
// Notify the application that the initial tree is rendered (with children)
// and that initial node selection can take place. App will check URL to
@@ -178,9 +198,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
@@ -230,8 +249,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)
@@ -240,38 +257,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
}
@@ -393,23 +403,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) {//{{{
@@ -430,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement {
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)
@@ -462,7 +479,9 @@ export class N2TreeNode extends CustomHTMLElement {
this.tmpl.innerHTML = `