diff --git a/.gitignore b/.gitignore
index 3224e2d..36a0767 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,9 @@
!.wave/prompts/
wave.yaml
.env
+
+# Build artifacts
+/librenotes
+/dist/
+*.test
+*.out
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9f98c8d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,23 @@
+The MIT License (MIT)
+
+Copyright (c) 2023 Alon Swartz
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..125c0b0
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,13 @@
+librenotes
+==========
+
+Cloud-native multi-tenant notes application.
+
+This project is a fork of Notesium (https://github.com/alonswartz/notesium),
+created and maintained by Alon Swartz, licensed under the MIT License.
+
+The original Notesium code is preserved under internal/notesium/ and remains
+under the MIT license. See LICENSE for the full license text.
+
+Modifications and additions made for librenotes are also released under the
+MIT License.
diff --git a/cmd/librenotes/main.go b/cmd/librenotes/main.go
new file mode 100644
index 0000000..1f42177
--- /dev/null
+++ b/cmd/librenotes/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "git.librete.ch/public/librenotes/internal/notesium"
+
+func main() {
+ notesium.Run()
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0dfa610
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,18 @@
+module git.librete.ch/public/librenotes
+
+go 1.20
+
+require (
+ github.com/charlievieth/fastwalk v1.0.9 // indirect
+ github.com/gdamore/encoding v1.0.1 // indirect
+ github.com/gdamore/tcell/v2 v2.8.1 // indirect
+ github.com/junegunn/fzf v0.58.0 // indirect
+ github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/term v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b1edd03
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,87 @@
+github.com/charlievieth/fastwalk v1.0.9 h1:Odb92AfoReO3oFBfDGT5J+nwgzQPF/gWAw6E6/lkor0=
+github.com/charlievieth/fastwalk v1.0.9/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
+github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
+github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/junegunn/fzf v0.58.0 h1:sT6lO4OTkHpEHpr8E1iZz6bvxZ6URHjTYl8/yhS8s1U=
+github.com/junegunn/fzf v0.58.0/go.mod h1:IsDYaa3WFbMYYi8yp92fQFTqN10hs3nH4OMBiz/kJXo=
+github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 h1:rqzLixVo1c/GQW6px9j1xQmlvQIn+lf/V6M1UQ7IFzw=
+github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
+golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/notesium/api.go b/internal/notesium/api.go
new file mode 100644
index 0000000..39e4a9a
--- /dev/null
+++ b/internal/notesium/api.go
@@ -0,0 +1,373 @@
+package notesium
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type NoteResponse struct {
+ Note
+ Path string `json:"Path"`
+ Content string `json:"Content"`
+}
+
+type NotePost struct {
+ Content string `json:"Content"`
+ Ctime time.Time `json:"Ctime"`
+}
+
+type NotePatch struct {
+ Content string `json:"Content"`
+ LastMtime time.Time `json:"LastMtime"`
+}
+
+type NoteDelete struct {
+ LastMtime time.Time `json:"LastMtime"`
+}
+
+type ErrorResponse struct {
+ Error string `json:"Error"`
+ Code int `json:"Code"`
+}
+
+func respondWithError(w http.ResponseWriter, errMsg string, statusCode int) {
+ errorResponse := ErrorResponse{Error: errMsg, Code: statusCode}
+ errorJSON, _ := json.Marshal(errorResponse)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ w.Write(errorJSON)
+}
+
+func apiHeartbeat(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("Heartbeat received."))
+}
+
+func apiList(w http.ResponseWriter, r *http.Request) {
+ jsonResponse, err := json.Marshal(noteCache)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(jsonResponse)
+}
+
+func apiNote(dir string, w http.ResponseWriter, r *http.Request, readOnly bool) {
+ pathSegments := strings.Split(r.URL.Path, "/")
+
+ var filename string
+ if len(pathSegments) >= 4 {
+ filename = pathSegments[3]
+ }
+
+ switch r.Method {
+ case "GET":
+ if filename == "" {
+ respondWithError(w, "Filename not specified", http.StatusBadRequest)
+ return
+ }
+
+ case "POST":
+ if readOnly {
+ respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
+ return
+ }
+
+ if filename != "" {
+ respondWithError(w, "Filename should not be specified", http.StatusBadRequest)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var notePost NotePost
+ if err := json.Unmarshal(body, ¬ePost); err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if notePost.Ctime.IsZero() {
+ respondWithError(w, "Ctime field is required", http.StatusBadRequest)
+ return
+ }
+
+ epochInt := notePost.Ctime.Unix()
+ epochHex := fmt.Sprintf("%x", epochInt)
+ filename = fmt.Sprintf("%s.md", epochHex)
+
+ path := filepath.Join(dir, filename)
+ if _, err := os.Stat(path); err == nil {
+ respondWithError(w, "File already exists", http.StatusConflict)
+ return
+ }
+
+ if err := os.WriteFile(path, []byte(notePost.Content), 0644); err != nil {
+ respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ noteCache = nil
+ populateCache(dir)
+
+ case "PATCH":
+ if readOnly {
+ respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
+ return
+ }
+
+ if filename == "" {
+ respondWithError(w, "Filename not specified", http.StatusBadRequest)
+ return
+ }
+
+ if _, ok := noteCache[filename]; !ok {
+ respondWithError(w, "Note not found", http.StatusNotFound)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var notePatch NotePatch
+ if err := json.Unmarshal(body, ¬ePatch); err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if notePatch.Content == "" {
+ respondWithError(w, "Content field is required", http.StatusBadRequest)
+ return
+ }
+ if notePatch.LastMtime.IsZero() {
+ respondWithError(w, "LastMtime field is required", http.StatusBadRequest)
+ return
+ }
+
+ path := filepath.Join(dir, filename)
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound)
+ } else {
+ respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+
+ if !info.ModTime().Truncate(time.Second).UTC().Equal(notePatch.LastMtime.Truncate(time.Second).UTC()) {
+ respondWithError(w, "Refusing to overwrite. File changed on disk.", http.StatusConflict)
+ return
+ }
+
+ if err := os.WriteFile(path, []byte(notePatch.Content), 0644); err != nil {
+ respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ noteCache = nil
+ populateCache(dir)
+
+ case "DELETE":
+ if readOnly {
+ respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
+ return
+ }
+
+ if filename == "" {
+ respondWithError(w, "Filename not specified", http.StatusBadRequest)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var noteDelete NoteDelete
+ if err := json.Unmarshal(body, ¬eDelete); err != nil {
+ respondWithError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if noteDelete.LastMtime.IsZero() {
+ respondWithError(w, "LastMtime field is required", http.StatusBadRequest)
+ return
+ }
+
+ note, ok := noteCache[filename]
+ if !ok {
+ respondWithError(w, "Note not found", http.StatusNotFound)
+ return
+ }
+
+ path := filepath.Join(dir, filename)
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound)
+ } else {
+ respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+
+ if !info.ModTime().Truncate(time.Second).UTC().Equal(noteDelete.LastMtime.Truncate(time.Second).UTC()) {
+ respondWithError(w, "Refusing to delete. File changed on disk.", http.StatusConflict)
+ return
+ }
+
+ if len(note.IncomingLinks) > 0 {
+ respondWithError(w, "Refusing to delete. Note has IncomingLinks.", http.StatusConflict)
+ return
+ }
+
+ err = os.Remove(path)
+ if err != nil {
+ respondWithError(w, "Error deleting file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ noteCache = nil
+ populateCache(dir)
+
+ response := map[string]any{
+ "Filename": filename,
+ "Deleted": true,
+ }
+ jsonResponse, _ := json.Marshal(response)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write(jsonResponse)
+ return
+
+ default:
+ respondWithError(w, "Method not supported", http.StatusMethodNotAllowed)
+ }
+
+ note, ok := noteCache[filename]
+ if !ok {
+ respondWithError(w, "Note not found", http.StatusNotFound)
+ return
+ }
+
+ path := filepath.Join(dir, filename)
+ content, err := os.ReadFile(path)
+ if err != nil {
+ respondWithError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ noteResponse := NoteResponse{
+ Note: *note,
+ Path: path,
+ Content: string(content),
+ }
+
+ jsonResponse, err := json.Marshal(noteResponse)
+ if err != nil {
+ respondWithError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(jsonResponse)
+}
+
+func apiRuntime(dir string, w http.ResponseWriter, _ *http.Request, opts webOptions) {
+ runtimeResponse := GetRuntimeInfo(dir, opts)
+ jsonResponse, err := json.Marshal(runtimeResponse)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(jsonResponse)
+}
+
+//////////////////////////////// experimental ////////////////////////////////
+
+type bufferedResponseWriter struct {
+ buf bytes.Buffer
+ w http.ResponseWriter
+}
+
+func (rw *bufferedResponseWriter) Write(p []byte) (n int, err error) {
+ return rw.buf.Write(p)
+}
+
+func (rw *bufferedResponseWriter) Flush() {
+ rw.w.Write(rw.buf.Bytes())
+ if f, ok := rw.w.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
+func apiRaw(dir string, w http.ResponseWriter, r *http.Request) {
+ pathSegments := strings.Split(r.URL.Path, "/")
+ if len(pathSegments) < 4 || pathSegments[3] == "" {
+ http.Error(w, "no command specified", http.StatusNotFound)
+ return
+ }
+ command := pathSegments[3]
+
+ args := []string{command}
+ queryParameters := r.URL.Query()
+ for key, values := range queryParameters {
+ for _, value := range values {
+ if value == "true" {
+ arg := fmt.Sprintf("--%s", key)
+ args = append(args, arg)
+ } else if value != "" && value != "false" {
+ arg := fmt.Sprintf("--%s=%s", key, value)
+ args = append(args, arg)
+ }
+ }
+ }
+
+ cmd, err := parseOptions(args)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ writer := &bufferedResponseWriter{w: w}
+ defer writer.Flush()
+
+ switch cmd.Name {
+ case "new":
+ notesiumNew(dir, cmd.Options.(newOptions), writer)
+ case "list":
+ notesiumList(dir, cmd.Options.(listOptions), writer)
+ case "links":
+ notesiumLinks(dir, cmd.Options.(linksOptions), writer)
+ case "lines":
+ notesiumLines(dir, cmd.Options.(linesOptions), writer)
+ case "stats":
+ notesiumStats(dir, cmd.Options.(statsOptions), writer)
+ case "version":
+ notesiumVersion(cmd.Options.(versionOptions), writer)
+ default:
+ http.Error(w, fmt.Sprintf("unrecognized command: %s", command), http.StatusBadRequest)
+ return
+ }
+}
diff --git a/internal/notesium/cache.go b/internal/notesium/cache.go
new file mode 100644
index 0000000..8eb039b
--- /dev/null
+++ b/internal/notesium/cache.go
@@ -0,0 +1,142 @@
+package notesium
+
+import (
+ "bufio"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Link struct {
+ Filename string
+ Title string
+ LineNumber int
+}
+
+type Note struct {
+ Filename string
+ Title string
+ IsLabel bool
+ OutgoingLinks []*Link
+ IncomingLinks []*Link
+ Ctime time.Time
+ Mtime time.Time
+ Lines int
+ Words int
+ Chars int
+}
+
+var noteCache map[string]*Note
+var fileRegex = regexp.MustCompile(`^[0-9a-f]{8}\.md$`)
+var linkRegex = regexp.MustCompile(`\]\(([0-9a-f]{8}\.md)\)`)
+
+func populateCache(dir string) {
+ if noteCache != nil {
+ return
+ }
+
+ noteCache = make(map[string]*Note)
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ log.Fatalf("could not read directory: %s\n", err)
+ }
+
+ for _, file := range files {
+ filename := file.Name()
+ if !file.IsDir() && fileRegex.MatchString(filename) {
+ note, err := readNote(dir, filename)
+ if err != nil {
+ log.Fatalf("could not read note: %s\n", err)
+ }
+ noteCache[filename] = note
+ }
+ }
+
+ for _, note := range noteCache {
+ for _, link := range note.OutgoingLinks {
+ if targetNote, exists := noteCache[link.Filename]; exists {
+ link.Title = targetNote.Title
+ targetNote.IncomingLinks = append(targetNote.IncomingLinks, &Link{
+ Filename: note.Filename,
+ Title: note.Title,
+ LineNumber: link.LineNumber,
+ })
+ }
+ }
+ }
+}
+
+func readNote(dir string, filename string) (*Note, error) {
+ path := filepath.Join(dir, filename)
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("could not open file: %s", err)
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("could not get file info: %s", err)
+ }
+ mtime := info.ModTime().Truncate(time.Second)
+
+ hexTime := strings.TrimSuffix(filename, ".md")
+ unixTime, err := strconv.ParseInt(hexTime, 16, 64)
+ if err != nil {
+ return nil, err
+ }
+ ctime := time.Unix(unixTime, 0)
+
+ var title string
+ var isLabel bool
+ var outgoingLinks []*Link
+ var lines, words, chars int
+
+ scanner := bufio.NewScanner(file)
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := scanner.Text()
+ if line != "" {
+ lines++
+ words += len(strings.Fields(line))
+ chars += len(line)
+ }
+ if title == "" {
+ title = strings.TrimPrefix(line, "# ")
+ isLabel = len(strings.Fields(title)) == 1
+ continue
+ }
+ matches := linkRegex.FindAllStringSubmatch(line, -1)
+ for _, match := range matches {
+ outgoingLinks = append(outgoingLinks, &Link{LineNumber: lineNumber, Filename: match[1]})
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ if title == "" {
+ title = "untitled"
+ }
+
+ note := &Note{
+ Filename: filename,
+ Title: title,
+ IsLabel: isLabel,
+ OutgoingLinks: outgoingLinks,
+ Ctime: ctime,
+ Mtime: mtime,
+ Lines: lines,
+ Words: words,
+ Chars: chars,
+ }
+
+ return note, nil
+}
diff --git a/internal/notesium/completion.bash b/internal/notesium/completion.bash
new file mode 100644
index 0000000..dd2ed52
--- /dev/null
+++ b/internal/notesium/completion.bash
@@ -0,0 +1,49 @@
+__notesium_cmds() {
+ notesium help 2>&1 | awk '/^ [a-z\-]/ {print $1}'
+}
+
+__notesium_opts() {
+ notesium help 2>&1 | \
+ awk '/^ [a-z]/ {cmd=$1}; /^ --/ {print cmd, $1}' | \
+ awk -v cmd="^$1\ " '$0 ~ cmd {print $2}' | \
+ sed 's/--sort=WORD/--sort=ctime\n--sort=mtime\n--sort=alpha/' | \
+ sed 's/--prefix=WORD/--prefix=ctime\n--prefix=mtime\n--prefix=label/'
+}
+
+__notesium_complete() {
+ local words
+ case "${#COMP_WORDS[@]}" in
+ 2) words="$(__notesium_cmds)";;
+ *) words="$(__notesium_opts ${COMP_WORDS[1]})";;
+ esac
+
+ # handle options with equals. COMP_WORDBREAKS is global.
+ _get_comp_words_by_ref -n = cur prev
+
+ if [[ "${COMP_WORDS[1]}" == "finder" ]]; then
+ if [[ "${prev}" == "--" ]]; then
+ words="$(echo -e "list\nlinks\nlines")"
+ else
+ for ((i = 1; i < ${#COMP_WORDS[@]} - 1; i++)); do
+ if [[ "${COMP_WORDS[i]}" == "--" ]]; then
+ words="$(__notesium_opts "${COMP_WORDS[i+1]}")"
+ break
+ fi
+ done
+ fi
+ fi
+
+ case ${cur} in
+ --prefix=*|--sort=*)
+ prev="${cur%%=*}="
+ cur="${cur#*=}"
+ words="$(echo "$words" | awk -F "=" -v p="^$prev" '$0 ~ p {print $2}')"
+ COMPREPLY=($(compgen -W "$words" -- "${cur}"))
+ return 0
+ ;;
+ esac
+
+ COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}"))
+}
+
+complete -o default -F __notesium_complete notesium
diff --git a/internal/notesium/filter.go b/internal/notesium/filter.go
new file mode 100644
index 0000000..e0d58dd
--- /dev/null
+++ b/internal/notesium/filter.go
@@ -0,0 +1,83 @@
+package notesium
+
+import (
+ "strings"
+)
+
+func tokenizeFilterQuery(query string) []string {
+ tokens := []string{}
+ var currentToken string
+ var inQuotes bool
+ var quoteChar rune
+
+ for i := 0; i < len(query); i++ {
+ c := rune(query[i])
+
+ // If we're not in quotes and encounter a quote, we enter 'quote mode'
+ if !inQuotes && (c == '"' || c == '\'') {
+ inQuotes = true
+ quoteChar = c
+ continue
+ }
+
+ // If we are in quotes and encounter the same quoteChar, we exit 'quote mode'
+ if inQuotes && c == quoteChar {
+ inQuotes = false
+ continue
+ }
+
+ // If we're not in quotes and see a space, that's a token boundary
+ if !inQuotes && c == ' ' {
+ if currentToken != "" {
+ tokens = append(tokens, currentToken)
+ currentToken = ""
+ }
+ continue
+ }
+
+ // Otherwise, accumulate the character
+ currentToken += string(c)
+ }
+
+ // Append the last token if non-empty
+ if currentToken != "" {
+ tokens = append(tokens, currentToken)
+ }
+
+ return tokens
+}
+
+func evaluateFilterQuery(query string, input string) (bool, error) {
+ input = strings.ToLower(input)
+ tokens := tokenizeFilterQuery(strings.ToLower(query))
+ matches := true
+
+ for _, token := range tokens {
+ if strings.Contains(token, "|") {
+ // OR logic
+ terms := strings.Split(token, "|")
+ orMatch := false
+ for _, term := range terms {
+ if strings.Contains(input, term) {
+ orMatch = true
+ break
+ }
+ }
+ matches = matches && orMatch
+ } else if term, ok := strings.CutPrefix(token, "!"); ok {
+ // NOT logic
+ if strings.Contains(input, term) {
+ matches = false
+ break
+ }
+ } else {
+ // AND logic (implicit)
+ if !strings.Contains(input, token) {
+ matches = false
+ break
+ }
+ }
+ }
+
+ return matches, nil
+}
diff --git a/internal/notesium/filter_test.go b/internal/notesium/filter_test.go
new file mode 100644
index 0000000..c092858
--- /dev/null
+++ b/internal/notesium/filter_test.go
@@ -0,0 +1,180 @@
+package notesium
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestTokenizeFilterQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "No quotes, single tokens only",
+ input: "book physics !math",
+ expected: []string{"book", "physics", "!math"},
+ },
+ {
+ name: "Double-quoted phrase",
+ input: `"earth science" physics`,
+ expected: []string{"earth science", "physics"},
+ },
+ {
+ name: "Single-quoted phrase",
+ input: `book 'social science' biology`,
+ expected: []string{"book", "social science", "biology"},
+ },
+ {
+ name: "Mixed single and double quotes",
+ input: `"environmental science" 'earth science' math`,
+ expected: []string{"environmental science", "earth science", "math"},
+ },
+ {
+ name: "Unclosed quote (double)",
+ input: `"science math`,
+ // The entire remainder after the first quote goes into the same token
+ // This behavior depends on your parser design; you might decide to handle or error out.
+ expected: []string{"science math"},
+ },
+ {
+ name: "Unclosed quote (single)",
+ input: `'science math`,
+ expected: []string{"science math"},
+ },
+ {
+ name: "Multiple separate phrases with OR inside",
+ input: `"earth science"|chemistry !biology`,
+ // This gets tokenized into 3 tokens:
+ // 1. earth science|chemistry
+ // 2. !biology
+ //
+ // Because there's no space between "earth science"|chemistry,
+ // they remain in one token (the user might intend that).
+ expected: []string{"earth science|chemistry", "!biology"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tokenizeFilterQuery(tt.input)
+ if !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("got %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestEvaluateFilterQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ input string
+ expectedMatch bool
+ }{
+ // ----------------------------------
+ // Basic AND (space)
+ {
+ name: "Simple AND matches",
+ query: "book chemistry",
+ input: "I found a physics book today",
+ expectedMatch: false,
+ },
+ {
+ name: "Simple AND true",
+ query: "book physics",
+ input: "I found a physics book today",
+ expectedMatch: true,
+ },
+
+ // ----------------------------------
+ // OR logic (|)
+ {
+ name: "OR logic - one term found",
+ query: "science|math",
+ input: "I enjoy reading about science topics",
+ expectedMatch: true,
+ },
+ {
+ name: "OR logic - no term found",
+ query: "apple|banana",
+ input: "I love oranges",
+ expectedMatch: false,
+ },
+
+ // ----------------------------------
+ // NOT logic (!)
+ {
+ name: "NOT logic - excluded term present => false",
+ query: "book !math",
+ input: "I have a math book",
+ expectedMatch: false,
+ },
+ {
+ name: "NOT logic - excluded term absent => true",
+ query: "book !math",
+ input: "I have a science book",
+ expectedMatch: true,
+ },
+
+ // ----------------------------------
+ // Phrase testing (quotes)
+ {
+ name: "Double-quoted phrase present",
+ query: `"earth science"`,
+ input: "My earth science teacher is great",
+ expectedMatch: true,
+ },
+ {
+ name: "Double-quoted phrase absent",
+ query: `"earth science"`,
+ input: "I love rocket science",
+ expectedMatch: false,
+ },
+ {
+ name: "Single-quoted phrase present",
+ query: `book 'social science'`,
+ input: "I have a social science book for class",
+ // We want both "book" AND "social science" => expect true
+ expectedMatch: true,
+ },
+ {
+ name: "Single-quoted phrase absent",
+ query: `book 'social science'`,
+ input: "I have a math book",
+ // Missing "social science"
+ expectedMatch: false,
+ },
+
+ // ----------------------------------
+ // Combined logic with OR + phrase
+ {
+ name: "Phrase + OR logic pass",
+ query: `"earth science"|biology`,
+ input: "I am studying biology this semester",
+ // OR logic => "earth science" or "biology"
+ expectedMatch: true,
+ },
+ {
+ name: "Phrase + OR logic fail",
+ query: `"earth science"|biology`,
+ input: "I am studying math and physics",
+ expectedMatch: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := evaluateFilterQuery(tt.query, tt.input)
+ if err != nil {
+ t.Errorf("evaluateFilterQuery returned error: %v", err)
+ }
+
+ if got != tt.expectedMatch {
+ t.Errorf("query=%q input=%q => got %v, want %v",
+ tt.query, tt.input, got, tt.expectedMatch)
+ }
+ })
+ }
+}
diff --git a/internal/notesium/finder.go b/internal/notesium/finder.go
new file mode 100644
index 0000000..1a547dd
--- /dev/null
+++ b/internal/notesium/finder.go
@@ -0,0 +1,54 @@
+package notesium
+
+import (
+ "fmt"
+
+ fzf "github.com/junegunn/fzf/src"
+)
+
+type channelWriter struct {
+ ch chan string
+}
+
+func (cw *channelWriter) Write(p []byte) (n int, err error) {
+ str := string(p) // Convert bytes to string
+ cw.ch <- str // Send to channel
+ return len(p), nil
+}
+
+func runFinder(inputChan chan string, opts []string) ([]string, int, error) {
+ options, err := fzf.ParseOptions(false, opts)
+ if err != nil {
+ return nil, 2, fmt.Errorf("fzf error: %w", err)
+ }
+
+ outputChan := make(chan string)
+ resultChan := make(chan struct {
+ code int
+ err error
+ }, 1)
+
+ options.Input = inputChan
+ options.Output = outputChan
+
+ go func() {
+ code, runErr := fzf.Run(options)
+ close(outputChan)
+
+ resultChan <- struct {
+ code int
+ err error
+ }{code, runErr}
+
+ close(resultChan)
+ }()
+
+ var lines []string
+ for line := range outputChan {
+ lines = append(lines, line)
+ }
+
+ result := <-resultChan
+ return lines, result.code, result.err
+}
+
diff --git a/internal/notesium/heartbeat.go b/internal/notesium/heartbeat.go
new file mode 100644
index 0000000..f1c5d09
--- /dev/null
+++ b/internal/notesium/heartbeat.go
@@ -0,0 +1,51 @@
+package notesium
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+)
+
+var (
+ mu sync.Mutex
+ lastHeartbeat time.Time
+)
+
+func updateHeartbeat() {
+ mu.Lock()
+ lastHeartbeat = time.Now()
+ mu.Unlock()
+}
+
+func heartbeatH(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ updateHeartbeat()
+ next.ServeHTTP(w, r)
+ })
+}
+
+func heartbeatF(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ updateHeartbeat()
+ next(w, r)
+ }
+}
+
+func checkHeartbeat(server *http.Server) {
+ for {
+ time.Sleep(5 * time.Second)
+ mu.Lock()
+ if time.Since(lastHeartbeat) > 10*time.Second {
+ fmt.Println("No active client, stopping server.")
+ mu.Unlock()
+ if err := server.Shutdown(context.Background()); err != nil {
+ log.Fatalf("Server shutdown failed: %+v", err)
+ }
+ break
+ }
+ mu.Unlock()
+ }
+}
diff --git a/internal/notesium/highlight.go b/internal/notesium/highlight.go
new file mode 100644
index 0000000..345a547
--- /dev/null
+++ b/internal/notesium/highlight.go
@@ -0,0 +1,175 @@
+package notesium
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strings"
+)
+
+const (
+ ansiHeading = "\033[1m"
+ ansiBold = "\033[1m"
+ ansiItalic = "\033[3m"
+ ansiLink = "\033[34m"
+ ansiCodeBlock = "\033[33m"
+ ansiInlineCode = "\033[33m"
+ ansiBlockQuote = "\033[36m"
+ ansiListMarker = "\033[36m"
+ ansiReset = "\033[0m"
+)
+var ansiLineBg = func() string {
+ switch os.Getenv("NOTESIUM_FINDER_THEME") {
+ case "light":
+ return "\033[48;5;7m"
+ default:
+ return "\033[40m"
+ }
+}()
+var (
+ reBold = regexp.MustCompile(`\*\*(.*?)\*\*`)
+ reBoldAlt = regexp.MustCompile(`__(.*?)__`)
+ reItalic = regexp.MustCompile(`\*(.*?)\*`)
+ reItalicAlt = regexp.MustCompile(`_(.*?)_`)
+ reUnorderedList = regexp.MustCompile(`^(\s*[-+*]) `)
+ reOrderedList = regexp.MustCompile(`^(\s*\d+\.) `)
+ reInlineCode = regexp.MustCompile("`(.*?)`")
+ reLinkPlain = regexp.MustCompile(`(?:https?://|www\.)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/[^\s]*)?`)
+ reLinkMarkdown = regexp.MustCompile(`\[(.[^]]*?)\]\((.[^)]*?)\)`)
+ reAnsi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ reReset = regexp.MustCompile(`\x1b\[0m`)
+)
+
+func renderMarkdown(reader io.Reader, writer io.Writer, lineNumber int) {
+ inCodeBlock := false
+ scanner := bufio.NewScanner(reader)
+
+ for lineIndex := 1; scanner.Scan(); lineIndex++ {
+ line := scanner.Text()
+ highlightedLine := highlightLine(line, &inCodeBlock)
+ if lineNumber > 0 && lineNumber == lineIndex {
+ highlightedLine = highlightLineWithBackground(highlightedLine)
+ }
+ fmt.Fprintln(writer, highlightedLine)
+ }
+
+ if err := scanner.Err(); err != nil {
+ fmt.Fprintf(writer, "Error reading content: %v\n", err)
+ }
+}
+
+func highlightLine(line string, inCodeBlock *bool) string {
+ // Code blocks
+ if strings.HasPrefix(line, "```") {
+ *inCodeBlock = !*inCodeBlock
+ return ansiCodeBlock + line + ansiReset
+ }
+ if *inCodeBlock {
+ return ansiCodeBlock + line + ansiReset
+ }
+
+ // Headers
+ if strings.HasPrefix(line, "#") {
+ return ansiHeading + line + ansiReset
+ }
+
+ // Blockquotes
+ if strings.HasPrefix(line, "> ") {
+ return ansiBlockQuote + line + ansiReset
+ }
+
+ // Inline code
+ matches := reInlineCode.FindAllStringSubmatchIndex(line, -1)
+ if len(matches) > 0 {
+ return highlightLineWithInlineCode(line, matches)
+ }
+
+ return highlightString(line)
+}
+
+func highlightString(line string) string {
+ // Links
+ line = highlightLink(line, reLinkMarkdown, ansiLink)
+ line = highlightRegex(line, reLinkPlain, ansiLink, 0)
+
+ // Bold (**text** or __text__)
+ line = highlightRegex(line, reBold, ansiBold, 2)
+ line = highlightRegex(line, reBoldAlt, ansiBold, 2)
+
+ // Italic (*text* or _text_)
+ line = highlightRegex(line, reItalic, ansiItalic, 1)
+ line = highlightRegex(line, reItalicAlt, ansiItalic, 1)
+
+ // List markers
+ line = highlightRegex(line, reUnorderedList, ansiListMarker, 0)
+ line = highlightRegex(line, reOrderedList, ansiListMarker, 0)
+
+ return line
+}
+
+func highlightRegex(line string, re *regexp.Regexp, ansiCode string, markerLength int) string {
+ return re.ReplaceAllStringFunc(line, func(match string) string {
+ inner := match[markerLength : len(match)-markerLength]
+ return ansiCode + inner + ansiReset
+ })
+}
+
+func highlightLink(line string, re *regexp.Regexp, ansiCode string) string {
+ return re.ReplaceAllStringFunc(line, func(match string) string {
+ matches := re.FindStringSubmatch(match)
+ if len(matches) >= 2 {
+ title := matches[1]
+ return ansiCode + title + ansiReset
+ }
+ return match
+ })
+}
+
+func highlightLineWithInlineCode(line string, matches [][]int) string {
+ var builder strings.Builder
+ prevIndex := 0
+
+ for _, match := range matches {
+ start, end := match[0], match[1]
+ groupStart, groupEnd := match[2], match[3]
+
+ // Handle text before inline code
+ if start > prevIndex {
+ builder.WriteString(highlightString(line[prevIndex:start]))
+ }
+
+ builder.WriteString(ansiInlineCode)
+ builder.WriteString(line[groupStart:groupEnd])
+ builder.WriteString(ansiReset)
+
+ prevIndex = end
+ }
+
+ // Handle text after inline code
+ if prevIndex < len(line) {
+ builder.WriteString(highlightString(line[prevIndex:]))
+ }
+
+ return builder.String()
+}
+
+
+func highlightLineWithBackground(highlightedLine string) string {
+ // apply bg after resets to handle segments
+ highlightedLine = reReset.ReplaceAllStringFunc(highlightedLine, func(reset string) string {
+ return reset + ansiLineBg
+ })
+
+ // apply padding
+ termWidth := 79
+ visibleChars := len(reAnsi.ReplaceAllString(highlightedLine, ""))
+ requiredPadding := termWidth - visibleChars
+ if requiredPadding > 0 {
+ padding := strings.Repeat(" ", requiredPadding)
+ highlightedLine += ansiLineBg + padding
+ }
+
+ return ansiLineBg + highlightedLine + ansiReset
+}
diff --git a/internal/notesium/notesium.go b/internal/notesium/notesium.go
new file mode 100644
index 0000000..a3e4ac1
--- /dev/null
+++ b/internal/notesium/notesium.go
@@ -0,0 +1,605 @@
+package notesium
+
+import (
+ "bufio"
+ "embed"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+var buildtime = "unset"
+var gitversion = "unset"
+
+//go:embed completion.bash web/app
+var embedfs embed.FS
+var embedfsWebRoot = "web/app"
+
+func Run() {
+ cmd, err := parseOptions(os.Args[1:])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ switch cmd.Name {
+ case "help":
+ fmt.Print(usage)
+ os.Exit(1)
+ case "version":
+ notesiumVersion(cmd.Options.(versionOptions), os.Stdout)
+ return
+ }
+
+ notesiumDir, err := getNotesiumDir()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ switch cmd.Name {
+ case "home":
+ fmt.Println(notesiumDir)
+ case "new":
+ notesiumNew(notesiumDir, cmd.Options.(newOptions), os.Stdout)
+ case "list":
+ notesiumList(notesiumDir, cmd.Options.(listOptions), os.Stdout)
+ case "links":
+ notesiumLinks(notesiumDir, cmd.Options.(linksOptions), os.Stdout)
+ case "lines":
+ notesiumLines(notesiumDir, cmd.Options.(linesOptions), os.Stdout)
+ case "stats":
+ notesiumStats(notesiumDir, cmd.Options.(statsOptions), os.Stdout)
+ case "finder":
+ notesiumFinder(notesiumDir, cmd.Options.(finderOptions))
+ case "cat":
+ notesiumCat(notesiumDir, cmd.Options.(catOptions))
+ case "web":
+ notesiumWeb(notesiumDir, cmd.Options.(webOptions))
+ case "extract":
+ notesiumExtract(cmd.Options.(extractOptions))
+ }
+}
+
+func notesiumNew(dir string, opts newOptions, w io.Writer) {
+ ctime := time.Now()
+ if opts.ctime != "" {
+ var err error
+ ctime, err = time.ParseInLocation("2006-01-02T15:04:05", opts.ctime, time.Local)
+ if err != nil {
+ log.Fatalf("invalid ctime format: %v", err)
+ }
+ }
+
+ epochInt := ctime.Unix()
+ epochHex := fmt.Sprintf("%x", epochInt)
+ filename := fmt.Sprintf("%s.md", epochHex)
+ path := filepath.Join(dir, filename)
+
+ _, err := os.Stat(path)
+ fileExists := !os.IsNotExist(err)
+
+ if opts.verbose {
+ fmt.Fprintf(w, "path:%s\n", path)
+ fmt.Fprintf(w, "filename:%s\n", filename)
+ fmt.Fprintf(w, "epoch:%d\n", epochInt)
+ fmt.Fprintf(w, "ctime:%s\n", ctime.Format("2006-01-02T15:04:05-07:00"))
+ fmt.Fprintf(w, "exists:%t\n", fileExists)
+ } else {
+ fmt.Fprintf(w, "%s\n", path)
+ }
+}
+
+func notesiumList(dir string, opts listOptions, w io.Writer) {
+ populateCache(dir)
+ notes := getSortedNotes(opts.sortBy)
+
+ switch opts.limit {
+ case "labels":
+ for _, note := range notes {
+ if note.IsLabel {
+ fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
+ }
+ }
+ return
+ case "orphans":
+ for _, note := range notes {
+ if len(note.OutgoingLinks) == 0 && len(note.IncomingLinks) == 0 {
+ fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
+ }
+ }
+ return
+ }
+
+ switch opts.prefix {
+ case "label":
+ var notesWithoutLabelLinks []*Note
+ var outputLines []string
+ for _, note := range notes {
+ labelLinked := false
+ for _, link := range note.OutgoingLinks {
+ if linkNote, exists := noteCache[link.Filename]; exists && linkNote.IsLabel {
+ line := fmt.Sprintf("%s:1: %s%s%s %s", note.Filename, opts.color.Code, linkNote.Title, opts.color.Reset, note.Title)
+ if opts.sortBy == "alpha" {
+ outputLines = append(outputLines, line)
+ } else {
+ fmt.Fprintln(w, line)
+ }
+ labelLinked = true
+ }
+ }
+ if !labelLinked {
+ notesWithoutLabelLinks = append(notesWithoutLabelLinks, note)
+ }
+ }
+ if opts.sortBy == "alpha" {
+ sortLinesByField(outputLines, ": ", 1)
+ for _, line := range outputLines {
+ fmt.Fprintln(w, line)
+ }
+ }
+ for _, note := range notesWithoutLabelLinks {
+ fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
+ }
+ return
+ case "ctime":
+ for _, note := range notes {
+ dateStamp := getDateStamp(note.Ctime, opts.dateFormat)
+ fmt.Fprintf(w, "%s:1: %s%s%s %s\n", note.Filename, opts.color.Code, dateStamp, opts.color.Reset, note.Title)
+ }
+ return
+ case "mtime":
+ for _, note := range notes {
+ dateStamp := getDateStamp(note.Mtime, opts.dateFormat)
+ fmt.Fprintf(w, "%s:1: %s%s%s %s\n", note.Filename, opts.color.Code, dateStamp, opts.color.Reset, note.Title)
+ }
+ return
+ }
+
+ for _, note := range notes {
+ fmt.Fprintf(w, "%s:1: %s\n", note.Filename, note.Title)
+ }
+}
+
+func notesiumLinks(dir string, opts linksOptions, w io.Writer) {
+ populateCache(dir)
+
+ if opts.filename != "" {
+ note, exists := noteCache[opts.filename]
+ if !exists {
+ log.Printf("filename does not exist: %s\n", opts.filename)
+ return
+ }
+ switch opts.limit {
+ case "outgoing":
+ for _, link := range note.OutgoingLinks {
+ linkNote, exists := noteCache[link.Filename]
+ if exists {
+ fmt.Fprintf(w, "%s:1: %s\n", linkNote.Filename, linkNote.Title)
+ }
+ }
+ return
+ case "incoming":
+ for _, link := range note.IncomingLinks {
+ linkNote, exists := noteCache[link.Filename]
+ if exists {
+ fmt.Fprintf(w, "%s:%d: %s\n", linkNote.Filename, link.LineNumber, linkNote.Title)
+ }
+ }
+ return
+ default:
+ prefix := fmt.Sprintf("%soutgoing%s", opts.color.Code, opts.color.Reset)
+ for _, link := range note.OutgoingLinks {
+ linkNote, exists := noteCache[link.Filename]
+ if exists {
+ fmt.Fprintf(w, "%s:1: %s %s\n", linkNote.Filename, prefix, linkNote.Title)
+ }
+ }
+ prefix = fmt.Sprintf("%sincoming%s", opts.color.Code, opts.color.Reset)
+ for _, link := range note.IncomingLinks {
+ linkNote, exists := noteCache[link.Filename]
+ if exists {
+ fmt.Fprintf(w, "%s:%d: %s %s\n", linkNote.Filename, link.LineNumber, prefix, linkNote.Title)
+ }
+ }
+ }
+ return
+ }
+
+ switch opts.limit {
+ case "dangling":
+ for _, note := range noteCache {
+ for _, link := range note.OutgoingLinks {
+ _, exists := noteCache[link.Filename]
+ if !exists {
+ fmt.Fprintf(w, "%s:%d: %s%s%s → %s\n", note.Filename, link.LineNumber, opts.color.Code, note.Title, opts.color.Reset, link.Filename)
+ }
+ }
+ }
+ return
+ }
+
+ notes := getSortedNotes("alpha")
+ for _, note := range notes {
+ for _, link := range note.OutgoingLinks {
+ linkNote, exists := noteCache[link.Filename]
+ linkTitle := link.Filename
+ if exists {
+ linkTitle = linkNote.Title
+ }
+ fmt.Fprintf(w, "%s:%d: %s%s%s → %s\n", note.Filename, link.LineNumber, opts.color.Code, note.Title, opts.color.Reset, linkTitle)
+ }
+ }
+}
+
+func notesiumLines(dir string, opts linesOptions, w io.Writer) {
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ log.Fatalf("Could not read directory: %s\n", err)
+ }
+
+ for _, file := range files {
+ if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") {
+ filename := file.Name()
+ path := filepath.Join(dir, filename)
+ file, err := os.Open(path)
+ if err != nil {
+ log.Fatalf("Could not open file: %s\n", err)
+ }
+
+ var title string
+ scanner := bufio.NewScanner(file)
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+ if opts.prefix == "title" && title == "" && strings.HasPrefix(line, "# ") {
+ title = strings.TrimPrefix(line, "# ")
+ }
+ if opts.filter != "" {
+ matches, err := evaluateFilterQuery(opts.filter, line)
+ if err != nil {
+ fmt.Fprintf(w, "Error: %v\n", err)
+ continue
+ }
+ if !matches {
+ continue
+ }
+ }
+ if opts.prefix == "title" {
+ fmt.Fprintf(w, "%s:%d: %s%s%s %s\n", filename, lineNumber, opts.color.Code, title, opts.color.Reset, line)
+ } else {
+ fmt.Fprintf(w, "%s:%d: %s\n", filename, lineNumber, line)
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ log.Fatalf("scanner error: %s", err)
+ }
+
+ file.Close()
+ }
+ }
+}
+
+func notesiumStats(dir string, opts statsOptions, w io.Writer) {
+ populateCache(dir)
+
+ labels := 0
+ orphans := 0
+ links := 0
+ dangling := 0
+ lines := 0
+ words := 0
+ chars := 0
+
+ for _, note := range noteCache {
+ if note.IsLabel {
+ labels++
+ }
+ if len(note.OutgoingLinks) == 0 && len(note.IncomingLinks) == 0 {
+ orphans++
+ }
+ for _, link := range note.OutgoingLinks {
+ _, exists := noteCache[link.Filename]
+ if !exists {
+ dangling++
+ }
+ }
+
+ links += len(note.OutgoingLinks)
+ lines += note.Lines
+ words += note.Words
+ chars += note.Chars
+ }
+
+ keyFormat := opts.color.Code + (map[bool]string{true: "%-9s", false: "%s"}[opts.table]) + opts.color.Reset
+ fmt.Fprintf(w, keyFormat+" %d\n", "notes", len(noteCache))
+ fmt.Fprintf(w, keyFormat+" %d\n", "labels", labels)
+ fmt.Fprintf(w, keyFormat+" %d\n", "orphans", orphans)
+ fmt.Fprintf(w, keyFormat+" %d\n", "links", links)
+ fmt.Fprintf(w, keyFormat+" %d\n", "dangling", dangling)
+ fmt.Fprintf(w, keyFormat+" %d\n", "lines", lines)
+ fmt.Fprintf(w, keyFormat+" %d\n", "words", words)
+ fmt.Fprintf(w, keyFormat+" %d\n", "chars", chars)
+}
+
+func notesiumFinder(dir string, opts finderOptions) {
+ inputCmd, err := parseOptions(opts.input)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ theme := os.Getenv("NOTESIUM_FINDER_THEME")
+ var fzfColor string
+ switch theme {
+ case "light":
+ fzfColor = "bg:15,bg+:7,fg:241,fg+:241,hl:4,hl+:2,pointer:9,info:6"
+ default:
+ fzfColor = "bg:8,bg+:0,fg:12,fg+:12,hl:11,hl+:3,pointer:9,info:3"
+ }
+
+ optsFzf := []string{
+ "--ansi",
+ "--exact",
+ "--border",
+ "--reverse",
+ "--no-sort",
+ "--no-height",
+ "--no-scrollbar",
+ "--no-separator",
+ "--pointer=>",
+ "--delimiter=:",
+ "--with-nth=3..",
+ fmt.Sprintf("--color=%s", fzfColor),
+ fmt.Sprintf("--prompt=%s> ", opts.prompt),
+ }
+
+ if opts.preview {
+ executablePath, err := os.Executable()
+ if err != nil {
+ log.Fatalf("error resolving executable path: %v", err)
+ }
+
+ executablePath, err = filepath.EvalSymlinks(executablePath)
+ if err != nil {
+ log.Fatalf("error resolving symlinked executable path: %v", err)
+ }
+
+ optsFzf = append(optsFzf,
+ "--bind=ctrl-/:toggle-preview",
+ "--preview-window=+{2}-/2",
+ fmt.Sprintf("--preview=%s cat {}", executablePath),
+ )
+ }
+
+ inputChan := make(chan string)
+ go func() {
+ defer close(inputChan)
+ writer := &channelWriter{ch: inputChan}
+
+ switch inputCmd.Name {
+ case "list":
+ notesiumList(dir, inputCmd.Options.(listOptions), writer)
+ case "links":
+ notesiumLinks(dir, inputCmd.Options.(linksOptions), writer)
+ case "lines":
+ notesiumLines(dir, inputCmd.Options.(linesOptions), writer)
+ default:
+ log.Fatal("input command not supported: ", inputCmd.Name)
+ }
+ }()
+
+ results, code, err := runFinder(inputChan, optsFzf)
+ if code != 0 && code != 130 && err != nil {
+ fmt.Fprintf(os.Stderr, "Error running fzf: %v\n", err)
+ }
+ for _, line := range results {
+ fmt.Print(line)
+ }
+ os.Exit(code)
+}
+
+func notesiumCat(dir string, opts catOptions) {
+ path := filepath.Join(dir, opts.filename)
+ file, err := os.Open(path)
+ if err != nil {
+ log.Fatalf("Error opening file: %v\n", err)
+ }
+ defer file.Close()
+ renderMarkdown(file, os.Stdout, opts.lineNumber)
+}
+
+func notesiumWeb(dir string, opts webOptions) {
+ populateCache(dir)
+
+ var httpfs http.FileSystem
+
+ if opts.webroot == "embedded" {
+ subfs, err := fs.Sub(embedfs, embedfsWebRoot)
+ if err != nil {
+ log.Fatalf("embedded webroot sub error: %v", err)
+ }
+ httpfs = http.FS(subfs)
+ } else {
+ httpfs = http.Dir(opts.webroot)
+ }
+
+ ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", opts.host, opts.port))
+ if err != nil {
+ log.Fatalf("Failed to listen on a port: %v", err)
+ }
+ defer ln.Close()
+
+ url := "http://localhost:" + strings.Split(ln.Addr().String(), ":")[1]
+ server := &http.Server{
+ Addr: ln.Addr().String(),
+ }
+
+ http.Handle("/", heartbeatH(http.FileServer(httpfs)))
+ http.HandleFunc("/api/notes", heartbeatF(apiList))
+ http.HandleFunc("/api/notes/", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
+ apiNote(dir, w, r, opts.readOnly)
+ }))
+
+ http.HandleFunc("/api/runtime", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
+ apiRuntime(dir, w, r, opts)
+ }))
+
+ http.HandleFunc("/api/raw/", heartbeatF(func(w http.ResponseWriter, r *http.Request) {
+ apiRaw(dir, w, r)
+ }))
+
+ for uri, srcDir := range opts.mounts {
+ mountfs := http.Dir(srcDir)
+ http.Handle(uri, http.StripPrefix(uri, heartbeatH(http.FileServer(mountfs))))
+ }
+
+ var idleStopMsg string
+ if opts.heartbeat {
+ idleStopMsg = " (stop-on-idle enabled)"
+ http.HandleFunc("/api/heartbeat", heartbeatF(apiHeartbeat))
+ go checkHeartbeat(server)
+ }
+
+ if opts.launchBrowser {
+ go func() {
+ time.Sleep(500 * time.Millisecond)
+
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "linux":
+ cmd = exec.Command("xdg-open", url)
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("cmd", "/c", "start", url)
+ default:
+ log.Println("Unsupported OS for launching browser")
+ return
+ }
+
+ err := cmd.Start()
+ if err != nil {
+ log.Printf("Failed to launch the browser: %v", err)
+ }
+ }()
+ }
+
+ fmt.Printf("Serving on %s (bind address %s)\n", url, opts.host)
+ fmt.Printf("Press Ctrl+C to stop%s\n", idleStopMsg)
+ if err := server.Serve(ln); err != http.ErrServerClosed {
+ log.Fatalf("Server closed unexpected:%+v", err)
+ }
+}
+
+func notesiumExtract(opts extractOptions) {
+ switch opts.path {
+ case "":
+ var files []string
+ fs.WalkDir(embedfs, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ files = append(files, path)
+ }
+ return nil
+ })
+
+ for _, file := range files {
+ fmt.Println(file)
+ }
+
+ default:
+ content, err := fs.ReadFile(embedfs, opts.path)
+ if err != nil {
+ log.Fatalf("Failed to read file: %s", err)
+ }
+ fmt.Println(string(content))
+ }
+}
+
+func notesiumVersion(opts versionOptions, w io.Writer) {
+ version := getVersion(gitversion)
+
+ if opts.check {
+ latest, err := getLatestReleaseInfo()
+ if err != nil {
+ fmt.Fprintf(w, "Error getting latest release info: %v\n", err)
+ return
+ }
+
+ fmt.Fprintf(w, "Notesium %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
+
+ comparison := compareVersions(version, latest.Version)
+ switch comparison {
+ case -1:
+ publishedAt := latest.PublishedAt
+ if parsedTime, err := time.Parse(time.RFC3339, latest.PublishedAt); err == nil {
+ publishedAt = parsedTime.Local().Format("2006-01-02 15:04")
+ }
+ fmt.Fprintf(w, "A new release is available: %s (%s)\n", latest.Version, publishedAt)
+ fmt.Fprintf(w, "https://github.com/alonswartz/notesium/releases\n")
+ case 0:
+ fmt.Fprintf(w, "You are using the latest version\n")
+ case 1:
+ fmt.Fprintf(w, "You are using a newer version than latest: %s\n", latest.Version)
+ }
+
+ if opts.verbose {
+ fmt.Fprintf(w, "\ncomparison:%d\n", comparison)
+ fmt.Fprintf(w, "version:%s\n", version)
+ fmt.Fprintf(w, "gitversion:%s\n", gitversion)
+ fmt.Fprintf(w, "buildtime:%s\n", buildtime)
+ fmt.Fprintf(w, "platform:%s/%s\n", runtime.GOOS, runtime.GOARCH)
+ fmt.Fprintf(w, "latest.version:%s\n", latest.Version)
+ fmt.Fprintf(w, "latest.published:%s\n", latest.PublishedAt)
+ fmt.Fprintf(w, "latest.release:%s\n", latest.HTMLURL)
+ }
+ return
+ }
+
+ if opts.verbose {
+ fmt.Fprintf(w, "version:%s\n", version)
+ fmt.Fprintf(w, "gitversion:%s\n", gitversion)
+ fmt.Fprintf(w, "buildtime:%s\n", buildtime)
+ fmt.Fprintf(w, "platform:%s/%s\n", runtime.GOOS, runtime.GOARCH)
+ } else {
+ fmt.Fprintf(w, "%s\n", version)
+ }
+}
+
+func getDateStamp(t time.Time, dateFormat string) string {
+ dateStamp := t.Format(dateFormat)
+
+ // experimental: monday first day of week
+ if strings.Contains(dateStamp, "%V") {
+ _, week := t.ISOWeek()
+ dateStamp = strings.Replace(dateStamp, "%V", fmt.Sprintf("%02d", week), 1)
+ }
+
+ // experimental: sunday first day of week
+ if strings.Contains(dateStamp, "%U") {
+ _, week := t.ISOWeek()
+ if t.Weekday() == time.Sunday {
+ _, week = t.AddDate(0, 0, 1).ISOWeek()
+ if t.Month() == time.January && t.Day() == 1 {
+ week = 1
+ }
+ }
+ dateStamp = strings.Replace(dateStamp, "%U", fmt.Sprintf("%02d", week), 1)
+ }
+
+ return dateStamp
+}
diff --git a/internal/notesium/options.go b/internal/notesium/options.go
new file mode 100644
index 0000000..bc26b4d
--- /dev/null
+++ b/internal/notesium/options.go
@@ -0,0 +1,427 @@
+package notesium
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const usage = `Usage: notesium COMMAND [OPTIONS]
+
+Commands:
+ home Print path to notes directory
+ new Print path for a new note
+ --verbose Output key:value pairs of related info
+ --ctime= Use specified ctime instead of now (YYYY-MM-DDThh:mm:ss)
+ list Print list of notes
+ --color Color code prefix using ansi escape sequences
+ --labels Limit list to only label notes (ie. one word title)
+ --orphans Limit list to notes without outgoing or incoming links
+ --sort=WORD Sort list by date or alphabetically (ctime|mtime|alpha)
+ --prefix=WORD Prefix title with date or linked label (ctime|mtime|label)
+ --date=FORMAT Date format for ctime/mtime prefix (default: 2006-01-02)
+ links [filename] Print list of links
+ --color Color code using ansi escape sequences
+ --outgoing Limit list to outgoing links related to filename
+ --incoming Limit list to incoming links related to filename
+ --dangling Limit list to broken links
+ lines Print all lines of notes (ie. fulltext search)
+ --color Color code prefix using ansi escape sequences
+ --prefix=title Prefix each line with note title
+ --filter=QUERY Filter lines by query: AND (space), OR (|), NOT (!)
+ stats Print statistics
+ --color Color code using ansi escape sequences
+ --table Format as table with whitespace delimited columns
+ finder Start finder (interactive filter selection TUI)
+ --preview Display note preview (toggle with ctrl-/)
+ --prompt=STR Set custom prompt text
+ -- CMD [OPTS] Input (default: list --color --prefix=label --sort=alpha)
+ web Start web server
+ --webroot=PATH Path to web root to serve (default: embedded webroot)
+ --mount=DIR:URI Additional directory to serve under webroot (experimental)
+ --open-browser Launch default web browser with web server URL
+ --stop-on-idle Automatically stop when no activity is detected
+ --port=INT Port for web server to listen on (default: random)
+ --no-check Disable daily new version checks
+ --writable Allow writing of notes in NOTESIUM_DIR via API
+ extract [path] Print list of embedded files or contents of file path
+ version Print version
+ --verbose Output key:value pairs of related info
+ --check Check if a newer version is available
+
+Environment:
+ NOTESIUM_DIR Path to notes directory (default: $HOME/notes)
+`
+
+type Command struct {
+ Name string
+ Options any
+}
+
+type newOptions struct {
+ ctime string
+ verbose bool
+}
+
+type listOptions struct {
+ color Color
+ limit string
+ prefix string
+ sortBy string
+ dateFormat string
+}
+
+type linksOptions struct {
+ color Color
+ limit string
+ filename string
+}
+
+type linesOptions struct {
+ color Color
+ prefix string
+ filter string
+}
+
+type finderOptions struct {
+ input []string
+ prompt string
+ preview bool
+}
+
+type catOptions struct {
+ filename string
+ lineNumber int
+}
+
+type statsOptions struct {
+ color Color
+ table bool
+}
+
+type webOptions struct {
+ host string
+ port int
+ webroot string
+ heartbeat bool
+ launchBrowser bool
+ readOnly bool
+ check bool
+ mounts map[string]string
+}
+
+type extractOptions struct {
+ path string
+}
+
+type versionOptions struct {
+ verbose bool
+ check bool
+}
+
+type Color struct {
+ Code string
+ Reset string
+}
+
+func parseOptions(args []string) (Command, error) {
+ if len(args) < 1 {
+ return Command{Name: "help"}, nil
+ }
+
+ cmd := Command{Name: args[0]}
+ switch cmd.Name {
+ case "-h", "--help", "help":
+ cmd.Name = "help"
+ return cmd, nil
+
+ // backwards compat.
+ case "-v", "--version":
+ cmd.Name = "version"
+ cmd.Options = versionOptions{}
+ return cmd, nil
+
+ case "home":
+ if len(args) > 1 {
+ return cmd, fmt.Errorf("unrecognized option: %s", args[1])
+ }
+ return cmd, nil
+
+ case "new":
+ opts := newOptions{}
+ for _, opt := range args[1:] {
+ switch {
+ case opt == "--verbose":
+ opts.verbose = true
+ case strings.HasPrefix(opt, "--ctime="):
+ opts.ctime = strings.TrimPrefix(opt, "--ctime=")
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "list":
+ opts := listOptions{}
+ opts.dateFormat = "2006-01-02"
+ for _, opt := range args[1:] {
+ switch {
+ case opt == "--color":
+ opts.color = defaultColor()
+ case opt == "--labels":
+ opts.limit = "labels"
+ case opt == "--orphans":
+ opts.limit = "orphans"
+ case opt == "--prefix=ctime":
+ opts.prefix = "ctime"
+ case opt == "--prefix=mtime":
+ opts.prefix = "mtime"
+ case opt == "--prefix=label":
+ opts.prefix = "label"
+ case opt == "--sort=ctime":
+ opts.sortBy = "ctime"
+ case opt == "--sort=mtime":
+ opts.sortBy = "mtime"
+ case opt == "--sort=alpha":
+ opts.sortBy = "alpha"
+ case strings.HasPrefix(opt, "--date="):
+ opts.dateFormat = strings.TrimPrefix(opt, "--date=")
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "links":
+ opts := linksOptions{}
+ filenameRequired := false
+ for _, opt := range args[1:] {
+ switch {
+ case opt == "--color":
+ opts.color = defaultColor()
+ case opt == "--dangling":
+ opts.limit = "dangling"
+ case opt == "--outgoing":
+ opts.limit = map[bool]string{true: "", false: "outgoing"}[opts.limit == "incoming"]
+ filenameRequired = true
+ case opt == "--incoming":
+ opts.limit = map[bool]string{true: "", false: "incoming"}[opts.limit == "outgoing"]
+ filenameRequired = true
+ case strings.HasPrefix(opt, "--filename=") && strings.HasSuffix(opt, ".md"):
+ opts.filename = strings.TrimPrefix(opt, "--filename=")
+ case strings.HasSuffix(opt, ".md"):
+ opts.filename = opt
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ if opts.filename != "" && opts.limit == "dangling" {
+ return Command{}, fmt.Errorf("filename not supported")
+ }
+ if opts.filename == "" && filenameRequired {
+ return Command{}, fmt.Errorf("filename is required")
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "lines":
+ opts := linesOptions{}
+ for _, opt := range args[1:] {
+ switch {
+ case opt == "--color":
+ opts.color = defaultColor()
+ case opt == "--prefix=title":
+ opts.prefix = "title"
+ case strings.HasPrefix(opt, "--filter="):
+ opts.filter = strings.TrimPrefix(opt, "--filter=")
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "finder":
+ opts := finderOptions{}
+ opts.input = []string{"list", "--color", "--prefix=label", "--sort=alpha"}
+ for i, opt := range args[1:] {
+ if opt == "--" {
+ opts.input = args[i+2:]
+ if len(opts.input) == 0 {
+ return Command{}, fmt.Errorf("input command not specified")
+ }
+ break
+ }
+ switch {
+ case opt == "--preview":
+ opts.preview = true
+ case strings.HasPrefix(opt, "--prompt="):
+ opts.prompt = strings.TrimPrefix(opt, "--prompt=")
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "stats":
+ opts := statsOptions{}
+ for _, opt := range args[1:] {
+ switch opt {
+ case "--color":
+ opts.color = defaultColor()
+ case "--table":
+ opts.table = true
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "web":
+ opts := webOptions{}
+ opts.host = "127.0.0.1"
+ opts.port = 0
+ opts.readOnly = true
+ opts.webroot = "embedded"
+ opts.check = true
+ opts.mounts = make(map[string]string)
+ for _, opt := range args[1:] {
+ switch {
+ case strings.HasPrefix(opt, "--webroot="):
+ webrootStr := strings.TrimPrefix(opt, "--webroot=")
+ webrootAbs, err := getAbsDir(webrootStr)
+ if err != nil {
+ return Command{}, fmt.Errorf("webroot %v: %s", err, webrootAbs)
+ }
+ opts.webroot = webrootAbs
+ case opt == "--open-browser":
+ opts.launchBrowser = true
+ case opt == "--stop-on-idle":
+ opts.heartbeat = true
+ case strings.HasPrefix(opt, "--port="):
+ portStr := strings.TrimPrefix(opt, "--port=")
+ port, err := strconv.Atoi(portStr)
+ if err != nil || port < 1024 || port > 65535 {
+ return Command{}, fmt.Errorf("invalid or out of range port number: %s", portStr)
+ }
+ opts.port = port
+ case opt == "--no-check":
+ opts.check = false
+ case opt == "--writable":
+ opts.readOnly = false
+ case strings.HasPrefix(opt, "--mount="):
+ mountStr := strings.TrimPrefix(opt, "--mount=")
+ mountPattern := regexp.MustCompile(`^(.+?):(/[a-zA-Z0-9-_]+/)$`)
+ matches := mountPattern.FindStringSubmatch(mountStr)
+ if matches == nil {
+ return Command{}, fmt.Errorf("mount format mismatch: expected '%s'", mountPattern.String())
+ }
+ srcAbs, err := getAbsDir(matches[1])
+ if err != nil {
+ return Command{}, fmt.Errorf("mount source %v: %s", err, srcAbs)
+ }
+ opts.mounts[matches[2]] = srcAbs
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "cat":
+ opts := catOptions{}
+ opts.lineNumber = 0
+ if len(args) != 2 {
+ return Command{}, fmt.Errorf("filename not specified or too many arguments")
+ }
+
+ parts := strings.Split(args[1], ":")
+ opts.filename = parts[0]
+ if len(parts) > 1 {
+ if num, err := strconv.Atoi(parts[1]); err == nil && num > 0 {
+ opts.lineNumber = num
+ }
+ }
+
+ cmd.Options = opts
+ return cmd, nil
+
+ case "version":
+ opts := versionOptions{}
+ for _, opt := range args[1:] {
+ switch opt {
+ case "--verbose":
+ opts.verbose = true
+ case "--check":
+ opts.check = true
+ default:
+ return Command{}, fmt.Errorf("unrecognized option: %s", opt)
+ }
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ case "extract":
+ opts := extractOptions{}
+ for _, opt := range args[1:] {
+ opts.path = opt
+ }
+ cmd.Options = opts
+ return cmd, nil
+
+ default:
+ if strings.HasPrefix(cmd.Name, "-") {
+ return cmd, fmt.Errorf("unrecognized option: %s", cmd.Name)
+ }
+ return cmd, fmt.Errorf("unrecognized command: %s", cmd.Name)
+ }
+}
+
+func getNotesiumDir() (string, error) {
+ dir, exists := os.LookupEnv("NOTESIUM_DIR")
+ if !exists {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ dir = filepath.Join(home, "notes")
+ }
+ absDir, err := getAbsDir(dir)
+ if err != nil {
+ return "", fmt.Errorf("NOTESIUM_DIR %v: %s", err, absDir)
+ }
+ return absDir, nil
+}
+
+func getAbsDir(dir string) (string, error) {
+ absDir, err := filepath.Abs(dir)
+ if err != nil {
+ return dir, fmt.Errorf("failed to resolve absolute path: %v", err)
+ }
+ realDir, err := filepath.EvalSymlinks(absDir)
+ if err != nil {
+ return absDir, fmt.Errorf("does not exist")
+ }
+ info, err := os.Stat(realDir)
+ if err != nil {
+ return realDir, fmt.Errorf("does not exist")
+ }
+ if !info.IsDir() {
+ return realDir, fmt.Errorf("is not a directory")
+ }
+ return realDir, nil
+}
+
+func defaultColor() Color {
+ return Color{
+ Code: "\033[0;36m",
+ Reset: "\033[0m",
+ }
+}
diff --git a/internal/notesium/runtime.go b/internal/notesium/runtime.go
new file mode 100644
index 0000000..a190c2b
--- /dev/null
+++ b/internal/notesium/runtime.go
@@ -0,0 +1,82 @@
+package notesium
+
+import (
+ "fmt"
+ "runtime"
+)
+
+type WebInfo struct {
+ Webroot string `json:"webroot"`
+ Writable bool `json:"writable"`
+ StopOnIdle bool `json:"stop-on-idle"`
+ VersionCheck bool `json:"daily-version-check"`
+}
+
+type BuildInfo struct {
+ GitVersion string `json:"gitversion"`
+ Buildtime string `json:"buildtime"`
+ GoVersion string `json:"goversion"`
+ LatestReleaseURL string `json:"latest-release-url"`
+}
+
+type MemoryInfo struct {
+ MemoryAlloc string `json:"alloc"`
+ MemoryTotalAlloc string `json:"total-alloc"`
+ MemorySys string `json:"sys"`
+ MemoryLookups uint64 `json:"lookups"`
+ MemoryMallocs uint64 `json:"mallocs"`
+ MemoryFrees uint64 `json:"frees"`
+}
+
+type RuntimeResponse struct {
+ Home string `json:"home"`
+ Version string `json:"version"`
+ Platform string `json:"platform"`
+ Web WebInfo `json:"web"`
+ Build BuildInfo `json:"build"`
+ Memory MemoryInfo `json:"memory"`
+}
+
+func GetRuntimeInfo(dir string, webOpts webOptions) RuntimeResponse {
+ var memStats runtime.MemStats
+ runtime.ReadMemStats(&memStats)
+
+ return RuntimeResponse{
+ Home: dir,
+ Version: getVersion(gitversion),
+ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+ Web: WebInfo{
+ Webroot: webOpts.webroot,
+ Writable: !webOpts.readOnly,
+ StopOnIdle: webOpts.heartbeat,
+ VersionCheck: webOpts.check,
+ },
+ Build: BuildInfo{
+ GitVersion: gitversion,
+ Buildtime: buildtime,
+ GoVersion: runtime.Version(),
+ LatestReleaseURL: latestReleaseURL,
+ },
+ Memory: MemoryInfo{
+ MemoryAlloc: bytesToHumanReadable(memStats.Alloc),
+ MemoryTotalAlloc: bytesToHumanReadable(memStats.TotalAlloc),
+ MemorySys: bytesToHumanReadable(memStats.Sys),
+ MemoryLookups: memStats.Lookups,
+ MemoryMallocs: memStats.Mallocs,
+ MemoryFrees: memStats.Frees,
+ },
+ }
+}
+
+func bytesToHumanReadable(bytes uint64) string {
+ const unit = 1024
+ if bytes < unit {
+ return fmt.Sprintf("%d B", bytes)
+ }
+ div, exp := int64(unit), 0
+ for n := bytes / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
+}
diff --git a/internal/notesium/sort.go b/internal/notesium/sort.go
new file mode 100644
index 0000000..15f6653
--- /dev/null
+++ b/internal/notesium/sort.go
@@ -0,0 +1,49 @@
+package notesium
+
+import (
+ "sort"
+ "strings"
+)
+
+type SortByCtime []*Note
+type SortByMtime []*Note
+type SortByTitle []*Note
+
+func (n SortByCtime) Len() int { return len(n) }
+func (n SortByCtime) Less(i, j int) bool { return n[i].Ctime.After(n[j].Ctime) }
+func (n SortByCtime) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
+
+func (n SortByMtime) Len() int { return len(n) }
+func (n SortByMtime) Less(i, j int) bool { return n[i].Mtime.After(n[j].Mtime) }
+func (n SortByMtime) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
+
+func (n SortByTitle) Len() int { return len(n) }
+func (n SortByTitle) Less(i, j int) bool { return n[i].Title < n[j].Title }
+func (n SortByTitle) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
+
+func getSortedNotes(sortBy string) []*Note {
+ notes := make([]*Note, 0, len(noteCache))
+
+ for _, note := range noteCache {
+ notes = append(notes, note)
+ }
+
+ switch sortBy {
+ case "ctime":
+ sort.Sort(SortByCtime(notes))
+ case "mtime":
+ sort.Sort(SortByMtime(notes))
+ case "alpha":
+ sort.Sort(SortByTitle(notes))
+ }
+
+ return notes
+}
+
+func sortLinesByField(lines []string, separator string, fieldIndex int) {
+ sort.Slice(lines, func(i, j int) bool {
+ subI := strings.SplitN(lines[i], separator, fieldIndex+1)[fieldIndex]
+ subJ := strings.SplitN(lines[j], separator, fieldIndex+1)[fieldIndex]
+ return subI < subJ
+ })
+}
diff --git a/internal/notesium/version.go b/internal/notesium/version.go
new file mode 100644
index 0000000..0072a61
--- /dev/null
+++ b/internal/notesium/version.go
@@ -0,0 +1,119 @@
+package notesium
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// 1:semver (2:major 3:minor 4:patch 5:prerelease 6:prereleaseV) 7:commits 8:hash 9:dirty
+var gitVersionRegex = regexp.MustCompile(`^v((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|rc)(?:\.(0|[1-9]\d*))?)?)-(0|[1-9]\d*)-g([0-9a-fA-F]+)(-dirty)?$`)
+
+var latestReleaseURL = "https://api.github.com/repos/alonswartz/notesium/releases/latest"
+
+type releaseInfo struct {
+ Version string `json:"-"`
+ TagName string `json:"tag_name"`
+ HTMLURL string `json:"html_url"`
+ PublishedAt string `json:"published_at"`
+}
+
+func getVersion(gitVersion string) string {
+ if matches := gitVersionRegex.FindStringSubmatch(gitVersion); matches != nil {
+ semver := matches[1]
+ commits := matches[7]
+ isDirty := matches[9] == "-dirty"
+
+ if isDirty {
+ return fmt.Sprintf("%s+%s-dirty", semver, commits)
+ }
+
+ if commits != "0" {
+ return fmt.Sprintf("%s+%s", semver, commits)
+ }
+
+ return semver
+ }
+ return "0.0.0-dev"
+}
+
+func getLatestReleaseInfo() (releaseInfo, error) {
+ var release releaseInfo
+
+ req, err := http.NewRequest("GET", latestReleaseURL, nil)
+ if err != nil {
+ return release, fmt.Errorf("error creating request: %s", err)
+ }
+
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return release, fmt.Errorf("error making request: %s", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return release, fmt.Errorf("error status code: %d", resp.StatusCode)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return release, fmt.Errorf("error decoding response: %s", err)
+ }
+
+ if release.TagName == "" || release.HTMLURL == "" || release.PublishedAt == "" {
+ return release, fmt.Errorf("missing required field in response")
+ }
+
+ release.Version = strings.TrimPrefix(release.TagName, "v")
+ return release, nil
+}
+
+func compareVersions(v1, v2 string) int {
+ // returns -1 if v1 < v2, 1 if v1 > v2, and 0 if they are equal.
+ // v1: +build is ignored. -prerelease will decrement patch
+ // v2: +build is ignored. -prerelease not supported (set to 0.0.0)
+ normalize := func(v string, handlePreRelease bool) []int {
+ v = strings.SplitN(v, "+", 2)[0]
+ parts := strings.SplitN(v, "-", 2)
+ isPreRelease := len(parts) > 1
+
+ if isPreRelease && !handlePreRelease {
+ return []int{0, 0, 0}
+ }
+
+ versionParts := strings.Split(parts[0], ".")
+ for len(versionParts) < 3 {
+ versionParts = append(versionParts, "0")
+ }
+
+ intParts := make([]int, 3)
+ for i, part := range versionParts {
+ intParts[i], _ = strconv.Atoi(part)
+ }
+
+ if isPreRelease && handlePreRelease {
+ intParts[2]--
+ }
+
+ return intParts
+ }
+
+ v1Parts := normalize(v1, true)
+ v2Parts := normalize(v2, false)
+
+ for i := 0; i < 3; i++ {
+ if v1Parts[i] < v2Parts[i] {
+ return -1
+ } else if v1Parts[i] > v2Parts[i] {
+ return 1
+ }
+ }
+
+ return 0
+}
diff --git a/internal/notesium/version_test.go b/internal/notesium/version_test.go
new file mode 100644
index 0000000..942b701
--- /dev/null
+++ b/internal/notesium/version_test.go
@@ -0,0 +1,75 @@
+package notesium
+
+import (
+ "testing"
+)
+
+func TestGetVersion(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"v0.1.2-0-g1234567", "0.1.2"},
+ {"v0.1.2-2-g1234567", "0.1.2+2"},
+ {"v0.1.2-0-g1234567-dirty", "0.1.2+0-dirty"},
+ {"v0.1.2-2-g1234567-dirty", "0.1.2+2-dirty"},
+ {"v0.2.0-beta-0-g1234567", "0.2.0-beta"},
+ {"v0.2.0-beta-0-g1234567-dirty", "0.2.0-beta+0-dirty"},
+ {"v0.2.0-beta-2-g1234567", "0.2.0-beta+2"},
+ {"v0.2.0-rc.2-0-g1234567", "0.2.0-rc.2"},
+ {"v0.2.0-rc.2-0-g1234567-dirty", "0.2.0-rc.2+0-dirty"},
+ {"v0.2.0-rc.2-2-g1234567", "0.2.0-rc.2+2"},
+ {"v0.1.2-0-g1234567-foo", "0.0.0-dev"},
+ {"v0.1.2-foo-g1234567", "0.0.0-dev"},
+ {"0.1.2-0-g1234567", "0.0.0-dev"},
+ {"unset", "0.0.0-dev"},
+ {"foo", "0.0.0-dev"},
+ {"", "0.0.0-dev"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := getVersion(tt.input)
+ if result != tt.expected {
+ t.Errorf("getVersion(%s), want %s", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestCompareVersions(t *testing.T) {
+ // -1 if v1 < v2, 1 if v1 > v2, and 0 if they are equal.
+ tests := []struct {
+ v1 string
+ v2 string
+ expected int
+ }{
+ {"", "", 0},
+ {"1.2.3", "", 1},
+ {"0.0.0", "", 0},
+ {"0.0.0-dev", "", -1},
+ {"0.0.0-dev", "1.2.3", -1},
+ {"1.2.3", "1.2.2", 1},
+ {"1.2.3", "1.2.3", 0},
+ {"1.2.3", "1.2.4", -1},
+ {"1.2.3+2", "1.2.2", 1},
+ {"1.2.3+2", "1.2.3", 0},
+ {"1.2.3+2", "1.2.4", -1},
+ {"1.2.3-beta", "1.2.1", 1},
+ {"1.2.3-beta", "1.2.2", 0},
+ {"1.2.3-beta", "1.2.3", -1},
+ {"1.2.3-beta", "1.2.4", -1},
+ {"1.2.0-beta", "1.2.0", -1},
+ {"1.2.0-beta", "1.2.4-beta", 1},
+ {"1.2.1-beta.2", "1.2.1", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.v1+"_"+tt.v2, func(t *testing.T) {
+ result := compareVersions(tt.v1, tt.v2)
+ if result != tt.expected {
+ t.Errorf("compareVersions(%s, %s) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/notesium/web/app/.gitignore b/internal/notesium/web/app/.gitignore
new file mode 100644
index 0000000..5cd0425
--- /dev/null
+++ b/internal/notesium/web/app/.gitignore
@@ -0,0 +1,4 @@
+.vendor/
+vendor.js
+vendor.css
+tailwind.css
diff --git a/internal/notesium/web/app/alert.js b/internal/notesium/web/app/alert.js
new file mode 100644
index 0000000..5b37606
--- /dev/null
+++ b/internal/notesium/web/app/alert.js
@@ -0,0 +1,40 @@
+var t = `
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ props: ['alert'],
+ emits: ['alert-dismiss'],
+ methods: {
+ dismiss() {
+ this.$emit('alert-dismiss', this.alert.id);
+ },
+ },
+ mounted() {
+ if (!this.alert.sticky) {
+ setTimeout(() => { this.dismiss(); }, 2000);
+ }
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/app.js b/internal/notesium/web/app/app.js
new file mode 100644
index 0000000..33c2551
--- /dev/null
+++ b/internal/notesium/web/app/app.js
@@ -0,0 +1,480 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Finder from './finder.js'
+import NavTabs from './nav-tabs.js'
+import Ribbon from './ribbon.js'
+import SidePanel from './sidepanel.js'
+import GraphPanel from './graph-panel.js'
+import Note from './note.js'
+import Periodic from './periodic.js'
+import Graph from './graph.js'
+import Empty from './empty.js'
+import Alert from './alert.js'
+import Confirm from './confirm.js'
+import Settings from './settings.js'
+import { formatDate } from './dateutils.js';
+import { initCodeMirrorVimEx } from './cm-vim.js'
+export default {
+ components: { Finder, NavTabs, Ribbon, SidePanel, GraphPanel, Note, Periodic, Graph, Empty, Alert, Confirm, Settings },
+ data() {
+ return {
+ notes: [],
+ tabs: [],
+ tabHistory: [],
+ finderUri: '',
+ finderQuery: '',
+ showGraph: false,
+ showFinder: false,
+ showPeriodic: false,
+ showSettings: false,
+ versionCheck: {},
+ keySequence: [],
+ alerts: [],
+ lastSave: null,
+ graphPanelWatcher: null,
+ }
+ },
+ methods: {
+ openFinder(uri, query) {
+ this.finderUri = uri;
+ this.finderQuery = query;
+ this.showFinder = true;
+ },
+ handleFinderSelection(value) {
+ this.showFinder = false;
+ this.finderQuery = '';
+ if (value === null) {
+ this.refocusActiveTab();
+ } else {
+ const note = this.notes.find(note => note.Filename === value.Filename);
+ if (note) {
+ note.Linenum = value.Linenum;
+ this.activateTab(value.Filename);
+ } else {
+ this.fetchNote(value.Filename, value.Linenum);
+ }
+ }
+ },
+ fetchNote(filename, linenum, insertAfterActive = false) {
+ fetch("/api/notes/" + filename)
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(note => {
+ note.Linenum = linenum;
+ this.notes.push(note);
+ this.addTab('note', note.Filename, insertAfterActive);
+ this.activateTab(note.Filename);
+ })
+ .catch(e => {
+ this.addAlert({type: 'error', title: 'Error fetching note', body: e.Error, sticky: true})
+ });
+ },
+ saveNote(filename, content, timestamp, isGhost) {
+ let uri;
+ let params = { method: null, body: null, headers: {"Content-type": "application/json"} }
+ if (isGhost) {
+ uri = "/api/notes/";
+ params.method = "POST";
+ params.body = JSON.stringify({Content: content, Ctime: timestamp});
+ } else {
+ uri = "/api/notes/" + filename;
+ params.method = "PATCH"
+ params.body = JSON.stringify({ Content: content, LastMtime: timestamp });
+ }
+ fetch(uri, params)
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(note => {
+ const index = this.notes.findIndex(n => n.Filename === filename);
+
+ // trigger graph panel refresh if needed
+ if (this.$notesiumState.showGraphPanel) {
+ if (params.method == "POST") {
+ this.graphPanelWatcher = note.Mtime;
+ } else if (this.notes[index].Title !== note.Title) {
+ this.graphPanelWatcher = note.Mtime;
+ } else {
+ const stringifyLinks = (links) => { return JSON.stringify(links?.map(link => link.Filename).sort() || []); }
+ if (stringifyLinks(this.notes[index].OutgoingLinks) !== stringifyLinks(note.OutgoingLinks)) {
+ this.graphPanelWatcher = note.Mtime;
+ }
+ }
+ }
+
+ this.notes[index] = note;
+ this.activateTab(note.Filename);
+
+ // track lastSave to force sidepanel refresh
+ this.lastSave = note.Mtime;
+
+ // update other notes IncomingLinks due to potential changes
+ this.notes.forEach(openNote => {
+ if (openNote.Filename == note.Filename) return;
+ if (openNote.ghost) return;
+ fetch("/api/notes/" + openNote.Filename)
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(fetchedNote => { openNote.IncomingLinks = fetchedNote.IncomingLinks; })
+ .catch(e => { console.error('Error fetching note for IncomingLinks: ', e); });
+ });
+ })
+ .catch(e => {
+ this.addAlert({type: 'error', title: 'Error saving note', body: e.Error, sticky: true})
+ });
+ },
+ async deleteNote(filename, timestamp) {
+ const note = this.notes.find(note => note.Filename === filename);
+ if (!note) return;
+ if (note.ghost) return;
+ if (note.isModified) { this.addAlert({type: 'error', title: 'Note has unsaved changes'}); return; }
+ if ((note.IncomingLinks?.length || 0) > 0) { this.addAlert({type: 'error', title: 'Refusing deletion, note has incoming links'}); return; }
+
+ const confirmCfg = {
+ title: 'Delete note',
+ body: `Are you sure you want to delete this note? This action cannot be undone.\n\n${note.Filename}: ${note.Title}`,
+ button: 'Delete note',
+ };
+ if (!await this.$refs.confirmDialog.open(confirmCfg)) return;
+
+ let params = {};
+ params.method = 'DELETE';
+ params.body = JSON.stringify({ LastMtime: timestamp });
+ params.headers = {"Content-type": "application/json"};
+ fetch("/api/notes/" + filename, params)
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(response => {
+ this.addAlert({type: 'success', title: 'Note deleted successfully'});
+ this.closeNote(filename);
+
+ // update lastSave to force sidepanel refresh
+ this.lastSave = new Date().toISOString();
+
+ // trigger graph panel refresh if needed
+ this.graphPanelWatcher = this.lastSave;
+
+ // update other notes IncomingLinks due to potential changes
+ this.notes.forEach(openNote => {
+ if (openNote.ghost) return;
+ fetch("/api/notes/" + openNote.Filename)
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(fetchedNote => { openNote.IncomingLinks = fetchedNote.IncomingLinks; })
+ .catch(e => { console.error('Error fetching note for IncomingLinks: ', e); });
+ });
+ })
+ .catch(e => {
+ this.addAlert({type: 'error', title: 'Error deleting note', body: e.Error, sticky: true})
+ });
+ },
+ newNote(ctime, content) {
+ const baseUri = '/api/raw/new';
+ let params = new URLSearchParams({ verbose: 'true' });
+ if (ctime) params.append('ctime', ctime);
+ const uri = `${baseUri}?${params.toString()}`;
+ fetch(uri)
+ .then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
+ .then(text => {
+ const noteInfo = text.trim().split('\n').reduce((dict, line) => {
+ const [key, value] = line.split(/:(.+)/); dict[key] = value;
+ return dict;
+ }, {});
+
+ if (noteInfo.exists === "true") { this.openNote(noteInfo.filename, 1); return; }
+
+ const index = this.notes.findIndex(note => note.Filename === noteInfo.filename);
+ if (index !== -1) { this.activateTab(noteInfo.filename); return; }
+
+ const ghost = {
+ Filename: noteInfo.filename,
+ Title: 'untitled',
+ Content: content ? content : '',
+ isModified: content ? true : false,
+ Mtime: '0',
+ Ctime: noteInfo.ctime,
+ ghost: true,
+ };
+ this.notes.push(ghost);
+ this.addTab('note', ghost.Filename);
+ this.activateTab(ghost.Filename);
+ })
+ .catch(e => {
+ this.addAlert({type: 'error', title: 'Error retrieving new note metadata', body: e.Error, sticky: true});
+ });
+ },
+ dailyNote(customDate = null) {
+ const date = customDate ? new Date(customDate) : new Date();
+ const ctime = formatDate(date, '%Y-%m-%dT00:00:00');
+ const content = `# ${formatDate(date, '%b %d, %Y (%A)')}`;
+ this.newNote(ctime, content);
+ },
+ weeklyNote(customDate = null) {
+ const date = customDate ? new Date(customDate) : new Date();
+ const epoch = date.getTime() / 1000;
+ const day = date.getDay() === 0 ? 7 : date.getDay();
+ const diff = (day - this.$notesiumState.startOfWeek + 7) % 7;
+ const weekBegEpoch = epoch - (diff * 86400);
+ const weekBegDate = new Date(weekBegEpoch * 1000);
+ const weekBegStr = formatDate(weekBegDate, '%a %b %d');
+ const weekEndEpoch = weekBegEpoch + (6 * 86400);
+ const weekEndDate = new Date(weekEndEpoch * 1000);
+ const weekEndStr = formatDate(weekEndDate, '%a %b %d');
+ const year = formatDate(weekBegDate, '%Y');
+ const weekFmt = this.$notesiumState.startOfWeek === 0 ? '%U' : '%V';
+ const weekNum = formatDate(weekBegDate, weekFmt);
+
+ const ctime = formatDate(weekBegDate, '%Y-%m-%dT00:00:01');
+ const content = `# ${year}: Week${weekNum} (${weekBegStr} - ${weekEndStr})`;
+ this.newNote(ctime, content);
+ },
+ openNote(filename, linenum) {
+ const index = this.notes.findIndex(note => note.Filename === filename);
+ if (index !== -1) {
+ this.notes[index].Linenum = linenum;
+ this.activateTab(filename);
+ } else {
+ this.fetchNote(filename, linenum, true);
+ }
+ },
+ addTab(tabType, tabId, insertAfterActive = false) {
+ const tab = {type: tabType, id: tabId}
+ const index = this.tabs.findIndex(t => t.id === this.activeTabId);
+ if (insertAfterActive && index !== -1) {
+ this.tabs.splice(index + 1, 0, tab);
+ } else {
+ this.tabs.push(tab);
+ }
+ },
+ activateTab(tabId) {
+ if (tabId == this.activeTabId) return;
+ this.tabHistory = this.tabHistory.filter(id => id !== tabId);
+ this.tabHistory.push(tabId);
+ },
+ refocusActiveTab() {
+ // required for cancelled keybind and finder
+ if (!this.activeTabId) return;
+ const tabId = this.activeTabId;
+ this.tabHistory = this.tabHistory.filter(id => id !== tabId);
+ this.$nextTick(() => { this.activateTab(tabId) });
+ },
+ moveTab(tabId, newIndex) {
+ const index = this.tabs.findIndex(t => t.id === tabId);
+ if (index === -1) return;
+ this.tabs.splice(newIndex, 0, this.tabs.splice(index, 1)[0]);
+ },
+ closeTab(tabId) {
+ const index = this.tabs.findIndex(t => t.id === tabId);
+ if (index !== -1) this.tabs.splice(index, 1);
+ this.tabHistory = this.tabHistory.filter(id => id !== tabId);
+ },
+ async closeNote(filename, confirmIfModified = true) {
+ const index = this.notes.findIndex(note => note.Filename === filename);
+ if (index === -1) return;
+ if (this.notes[index].isModified && !this.notes[index].ghost && confirmIfModified) {
+ const confirmCfg = {
+ title: 'Note has unsaved changes',
+ body: `Are you sure you want to discard changes and close? This action cannot be undone.\n\n${filename}: ${this.notes[index].Title}`,
+ button: 'Close without saving',
+ };
+ if (!await this.$refs.confirmDialog.open(confirmCfg)) return;
+ }
+
+ this.notes.splice(index, 1);
+ this.closeTab(filename);
+ },
+ addAlert({type, title, body, sticky = false}) {
+ this.alerts.push({type, title, body, sticky, id: Date.now().toString(36)});
+ },
+ dismissAlert(id) {
+ const index = this.alerts.findIndex(alert => alert.id === id);
+ if (index !== -1) this.alerts.splice(index, 1);
+ },
+ checkVersion() {
+ this.versionCheck.error = '';
+ this.versionCheck.comparison = '';
+ this.versionCheck.latestVersion = '';
+ this.versionCheck.inprogress = true;
+ fetch('/api/raw/version?verbose=true&check=true')
+ .then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
+ .then(text => {
+ if (text.toLowerCase().startsWith('error')) {
+ this.versionCheck.error = text.trim();
+ return;
+ }
+ const lines = text.split('\n');
+ lines.forEach(line => {
+ const [key, ...rest] = line.split(':');
+ const value = rest.join(':').trim();
+ switch (key) {
+ case 'comparison': this.versionCheck.comparison = value; break;
+ case 'latest.version': this.versionCheck.latestVersion = value; break;
+ }
+ });
+ })
+ .catch(e => {
+ this.versionCheck.error = e.Error;
+ })
+ .finally(() => {
+ this.versionCheck.inprogress = false;
+ this.versionCheck.date = new Date();
+ });
+ },
+ handleBeforeUnload(event) {
+ if (this.notes.some(note => note.isModified)) {
+ const message = 'You have unsaved changes.';
+ event.returnValue = message;
+ return message;
+ }
+ },
+ handleKeyPress(event) {
+ if (event.target.tagName !== 'BODY') return
+
+ const leaderKey = 'Space'
+ if (this.keySequence.length == 0 && event.code == leaderKey) {
+ this.keySequence = [leaderKey];
+ event.preventDefault();
+ setTimeout(() => {
+ if (this.keySequence.length > 0) {
+ this.keySequence = [];
+ this.refocusActiveTab();
+ }
+ }, 2000);
+ return;
+ }
+
+ if (this.keySequence[0] == leaderKey) {
+ this.keySequence.push(event.code)
+ event.preventDefault();
+
+ switch(this.keySequence.join(' ')) {
+ case `${leaderKey} KeyN KeyL`:
+ this.keySequence = [];
+ this.openFinder('/api/raw/list?color=true&prefix=label&sort=alpha');
+ break;
+ case `${leaderKey} KeyN KeyC`:
+ this.keySequence = [];
+ this.openFinder('/api/raw/list?color=true&prefix=ctime&sort=ctime');
+ break;
+ case `${leaderKey} KeyN KeyM`:
+ this.keySequence = [];
+ this.openFinder('/api/raw/list?color=true&prefix=mtime&sort=mtime');
+ break;
+ case `${leaderKey} KeyN KeyK`:
+ this.keySequence = [];
+ const tab = this.tabs.find(t => t.id === this.activeTabId);
+ const extraParams = tab?.type === 'note' ? `&filename=${tab.id}` : '';
+ this.openFinder('/api/raw/links?color=true' + extraParams);
+ break;
+ case `${leaderKey} KeyN KeyS`:
+ this.keySequence = [];
+ this.openFinder('/api/raw/lines?color=true&prefix=title');
+ break;
+ case `${leaderKey} KeyN KeyN`:
+ this.keySequence = [];
+ this.newNote();
+ break;
+ case `${leaderKey} KeyN KeyD`:
+ this.keySequence = [];
+ this.dailyNote();
+ break;
+ case `${leaderKey} KeyN KeyW`:
+ this.keySequence = [];
+ this.weeklyNote();
+ break;
+ case `${leaderKey} KeyN KeyG`:
+ this.keySequence = [];
+ this.showGraph = true;
+ break;
+ }
+ }
+ },
+ handleHeartbeat(action) {
+ const sendHeartbeat = () => {
+ fetch("/api/heartbeat").then(r => { !r.ok && this.handleHeartbeat('stop') });
+ };
+ switch (action) {
+ case 'start':
+ !this.heartbeatInterval && (this.heartbeatInterval = setInterval(sendHeartbeat, 5000));
+ break;
+ case 'stop':
+ this.heartbeatInterval && clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ break;
+ }
+ },
+ handleCheckVersion(action) {
+ switch (action) {
+ case 'start':
+ this.checkVersion();
+ !this.checkVersionInterval && (this.checkVersionInterval = setInterval(this.checkVersion, 86400000)); // 24 hours
+ break;
+ case 'stop':
+ this.checkVersionInterval && clearInterval(this.checkVersionInterval);
+ this.checkVersionInterval = null;
+ break;
+ }
+ },
+ handleRuntimeWebOpts() {
+ fetch("/api/runtime")
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(runtime => {
+ if (runtime.web["stop-on-idle"]) this.handleHeartbeat('start');
+ if (runtime.web["daily-version-check"]) this.handleCheckVersion('start');
+ })
+ .catch(e => { console.error(e); });
+ },
+ },
+ computed: {
+ activeTabId() {
+ return this.tabHistory[this.tabHistory.length - 1] || '';
+ },
+ previousTabId() {
+ return this.tabHistory[this.tabHistory.length - 2] || '';
+ },
+ },
+ mounted() {
+ document.addEventListener('keydown', this.handleKeyPress);
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
+ this.handleRuntimeWebOpts();
+ initCodeMirrorVimEx(this.$notesiumState);
+ },
+ beforeUnmount() {
+ document.removeEventListener('keydown', this.handleKeyPress);
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
+ this.handleHeartbeat('stop');
+ this.handleCheckVersion('stop');
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/cm-table.js b/internal/notesium/web/app/cm-table.js
new file mode 100644
index 0000000..d3b2dd5
--- /dev/null
+++ b/internal/notesium/web/app/cm-table.js
@@ -0,0 +1,184 @@
+function isTableRow(cm, lineNum) {
+ return cm.getLine(lineNum).trim().startsWith('|');
+}
+
+function findTableBoundaries(cm, lineNum) {
+ let startLine = lineNum, endLine = lineNum;
+ while (startLine > 0 && isTableRow(cm, startLine - 1)) startLine--;
+ while (endLine < cm.lineCount() - 1 && isTableRow(cm, endLine + 1)) endLine++;
+ return { startLine, endLine };
+}
+
+function getColumnAlignments(cm, lineNum) {
+ return cm.getLine(lineNum).split('|').slice(1, -1).map(col => {
+ const trimmedCol = col.trim();
+ if (trimmedCol.startsWith(':') && trimmedCol.endsWith(':')) return 'center'
+ if (trimmedCol.endsWith(':')) return 'right';
+ return 'left';
+ });
+}
+
+function getColumnPositions(cm, lineNum) {
+ const lineText = cm.getLine(lineNum);
+ let positions = [];
+ let pos = lineText.indexOf('|');
+ while (pos !== -1) { positions.push(pos); pos = lineText.indexOf('|', pos + 1); }
+ return positions;
+}
+
+function getColumnMaxLengths(cm, startLine, endLine, conceal) {
+ let maxLengths = [];
+ for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
+ if (lineNum == startLine + 1) continue;
+ const columns = cm.getLine(lineNum).trim().split('|').map(col => col.trim());
+ columns.slice(1, -1).forEach((col, index) => {
+ const colLength = conceal ? getConcealLength(col) : col.length;
+ if (!maxLengths[index] || colLength > maxLengths[index]) {
+ maxLengths[index] = colLength;
+ }
+ });
+ }
+ return maxLengths;
+}
+
+function formatRowSep(cm, lineNum, colMaxLengths, colAlignments) {
+ const line = cm.getLine(lineNum);
+ let columns = line.split('|');
+ columns = columns.map((col, index) => {
+ if (index === 0 || index === columns.length - 1) return col;
+ switch (colAlignments[index - 1]) {
+ case 'center': return ` :${"-".repeat(colMaxLengths[index - 1] - 2)}: `;
+ case 'right': return ` ${"-".repeat(colMaxLengths[index - 1] - 1)}: `;
+ default: return ` ${"-".repeat(colMaxLengths[index - 1])} `;
+ }
+ });
+ cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length});
+}
+
+function formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal) {
+ const line = cm.getLine(lineNum);
+ let columns = line.split('|');
+ columns = columns.map((col, index) => {
+ if (index === 0 || index === columns.length - 1) return col;
+ const colTrimmed = col.trim();
+ const colLength = conceal ? getConcealLength(colTrimmed) : colTrimmed.length;
+ const paddingLength = Math.max(0, colMaxLengths[index - 1] - colLength);
+ const halfPadding = Math.floor(paddingLength / 2);
+ switch (colAlignments[index - 1]) {
+ case 'center': return ` ${' '.repeat(halfPadding)}${colTrimmed}${' '.repeat(paddingLength - halfPadding)} `;
+ case 'right': return ` ${' '.repeat(paddingLength)}${colTrimmed} `;
+ default: return ` ${colTrimmed}${' '.repeat(paddingLength)} `;
+ }
+ });
+ cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length});
+}
+
+function formatRows(cm, conceal) {
+ const cursorPos = cm.getCursor();
+ if (!isTableRow(cm, cursorPos.line)) return;
+
+ const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line);
+ const colAlignments = getColumnAlignments(cm, startLine + 1);
+ const colMaxLengths = getColumnMaxLengths(cm, startLine, endLine, conceal);
+
+ for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
+ if (lineNum == startLine + 1) {
+ formatRowSep(cm, lineNum, colMaxLengths, colAlignments);
+ } else {
+ formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal);
+ }
+ }
+}
+
+function addOrUpdateRowSep(cm) {
+ const cursorPos = cm.getCursor();
+ const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line);
+ if (startLine !== cursorPos.line) return;
+
+ const columnsCount = getColumnPositions(cm, startLine).length;
+ if (startLine == endLine) {
+ cm.replaceRange("\n" + `${"|".repeat(columnsCount)}`, {line: startLine, ch: cm.getLine(startLine).length});
+ return;
+ }
+
+ const sepLineNum = startLine + 1;
+ const sepLineText = cm.getLine(sepLineNum);
+ const columnsCountSep = getColumnPositions(cm, sepLineNum).length;
+ if (columnsCount > columnsCountSep) {
+ cm.replaceRange(`${"|".repeat(columnsCount - columnsCountSep)}`, {line: sepLineNum, ch: sepLineText.length});
+ } else {
+ const newSepLineText = sepLineText.split('|').slice(0, columnsCount).join('|') + '|';
+ cm.replaceRange(newSepLineText, {line: sepLineNum, ch: 0}, {line: sepLineNum, ch: sepLineText.length});
+ }
+}
+
+export function isCursorInTable(cm) {
+ const cursorPos = cm.getCursor();
+ return isTableRow(cm, cursorPos.line);
+}
+
+export function formatTableAndAdvance(cm, conceal) {
+ const cursorPos = cm.getCursor();
+ if (!isTableRow(cm, cursorPos.line)) return;
+
+ const currentPositions = getColumnPositions(cm, cursorPos.line);
+ const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length;
+
+ if (currentColumn == currentPositions.length) {
+ cm.replaceRange('|', {line: cursorPos.line, ch: cm.getLine(cursorPos.line).length});
+ addOrUpdateRowSep(cm);
+ formatRows(cm, conceal);
+ cm.setCursor(cursorPos.line, cm.getLine(cursorPos.line).length);
+ } else {
+ formatRows(cm, conceal);
+ const newPositions = getColumnPositions(cm, cursorPos.line);
+ cm.setCursor(cursorPos.line, newPositions[currentColumn] + 2);
+ }
+}
+
+export function navigateTable(cm, direction) {
+ const cursorPos = cm.getCursor();
+ if (!isTableRow(cm, cursorPos.line)) return CodeMirror.Pass;
+
+ const currentPositions = getColumnPositions(cm, cursorPos.line);
+ const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length;
+
+ const moveCursorVertically = (targetLine) => {
+ const targetPositions = getColumnPositions(cm, targetLine);
+ const targetCh = currentColumn <= targetPositions.length
+ ? targetPositions[currentColumn - 1] + 2
+ : cm.getLine(targetLine).length;
+ cm.setCursor(targetLine, targetCh);
+ };
+
+ switch (direction) {
+ case 'left':
+ if (currentColumn > 1) {
+ cm.setCursor(cursorPos.line, currentPositions[currentColumn - 2] + 2);
+ }
+ break;
+ case 'right':
+ if (currentColumn < currentPositions.length) {
+ cm.setCursor(cursorPos.line, currentPositions[currentColumn] + 2);
+ }
+ break;
+ case 'up':
+ const {startLine} = findTableBoundaries(cm, cursorPos.line);
+ if (cursorPos.line > startLine) moveCursorVertically(cursorPos.line - 1);
+ break;
+ case 'down':
+ const {endLine} = findTableBoundaries(cm, cursorPos.line);
+ if (cursorPos.line < endLine) moveCursorVertically(cursorPos.line + 1);
+ break;
+ }
+}
+
+function getConcealLength(s) {
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
+ s = s.replace(/(\*\*\*|___)(.*?)\1/g, '$2'); // Bold + Italic
+ s = s.replace(/(\*\*|__)(.*?)\1/g, '$2'); // Bold
+ s = s.replace(/(\*|_)(.*?)\1/g, '$2'); // Italic
+ s = s.replace(/(~~)(.*?)\1/g, '$2'); // Strikethrough
+ s = s.replace(/(`)(.*?)\1/g, '$2'); // Inline code
+ return s.length;
+}
diff --git a/internal/notesium/web/app/cm-vim.js b/internal/notesium/web/app/cm-vim.js
new file mode 100644
index 0000000..ee409dd
--- /dev/null
+++ b/internal/notesium/web/app/cm-vim.js
@@ -0,0 +1,81 @@
+export function initCodeMirrorVimEx(notesiumState) {
+ CodeMirror.Vim.defineEx('quit', 'q', (cm, cmd) => {
+ const confirmIfModified = cmd.argString !== '!';
+ if (cm.quit) cm.quit(confirmIfModified);
+ });
+
+ CodeMirror.Vim.defineEx('wq', '', (cm) => {
+ if (cm.writequit) cm.writequit();
+ });
+
+ CodeMirror.Vim.defineEx('cmExecCommand', '', (cm, cmd) => {
+ cm.execCommand(cmd.args[0]);
+ });
+ CodeMirror.Vim.map('zo', ':cmExecCommand unfold', 'normal');
+ CodeMirror.Vim.map('zc', ':cmExecCommand fold', 'normal');
+ CodeMirror.Vim.map('zR', ':cmExecCommand unfoldAll', 'normal');
+ CodeMirror.Vim.map('zM', ':cmExecCommand foldAll', 'normal');
+ CodeMirror.Vim.map('za', ':cmExecCommand toggleFold', 'normal');
+
+ CodeMirror.Vim.defineEx('OpenLinkUnderCursor', '', (cm) => {
+ if (!cm.openlink) return;
+ const cursor = cm.getCursor();
+ const lineContent = cm.getLine(cursor.line);
+ const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
+ const urlLinkRegex = /(?:https?:\/\/|www\.)[^\s)]+/g;
+ let match;
+ let link = null;
+ while ((match = mdLinkRegex.exec(lineContent)) !== null) {
+ const start = match.index;
+ const end = start + match[0].length;
+ if (cursor.ch >= start && cursor.ch <= end) { link = match[2]; break; }
+ }
+ if (!link) {
+ while ((match = urlLinkRegex.exec(lineContent)) !== null) {
+ const start = match.index;
+ const end = start + match[0].length;
+ if (cursor.ch >= start && cursor.ch <= end) { link = match[0]; break; }
+ }
+ }
+ if (link) cm.openlink(link);
+ });
+ CodeMirror.Vim.map('ge', ':OpenLinkUnderCursor', 'normal');
+ CodeMirror.Vim.map('gx', ':OpenLinkUnderCursor', 'normal');
+
+ CodeMirror.Vim.defineEx('BodyKeyEvent', '', (cm, cmd) => {
+ const key = cmd.args[0];
+ const code = cmd.args[1];
+ const ctrlKey = key.startsWith('', ':BodyKeyEvent Space', 'normal');
+ CodeMirror.Vim.map('', ':BodyKeyEvent KeyH', 'normal');
+ CodeMirror.Vim.map('', ':BodyKeyEvent KeyL', 'normal');
+ CodeMirror.Vim.map('', ':BodyKeyEvent Digit6', 'normal');
+ CodeMirror.Vim.map('', ':BodyKeyEvent KeyH', 'insert');
+ CodeMirror.Vim.map('', ':BodyKeyEvent KeyL', 'insert');
+ CodeMirror.Vim.map('', ':BodyKeyEvent Digit6', 'insert');
+
+ CodeMirror.Vim.defineOption('wrap', notesiumState.editorLineWrapping, 'boolean', [], (value, cm) => {
+ if (cm) return; // option is global, do nothing for local
+ if (value === undefined) return notesiumState.editorLineWrapping;
+ notesiumState.editorLineWrapping = value;
+ return value;
+ });
+
+ CodeMirror.Vim.defineOption('conceal', notesiumState.editorConcealFormatting, 'boolean', [], (value, cm) => {
+ if (cm) return; // option is global, do nothing for local
+ if (value === undefined) return notesiumState.editorConcealFormatting;
+ notesiumState.editorConcealFormatting = value;
+ return value;
+ });
+
+ CodeMirror.Vim.defineOption('fold', notesiumState.editorFoldGutter, 'boolean', [], (value, cm) => {
+ if (cm) return; // option is global, do nothing for local
+ if (value === undefined) return notesiumState.editorFoldGutter;
+ notesiumState.editorFoldGutter = value;
+ return value;
+ });
+}
diff --git a/internal/notesium/web/app/confirm.js b/internal/notesium/web/app/confirm.js
new file mode 100644
index 0000000..309221f
--- /dev/null
+++ b/internal/notesium/web/app/confirm.js
@@ -0,0 +1,54 @@
+var t = `
+
+`
+
+export default {
+ data() {
+ return {
+ config: {},
+ visible: false,
+ resolve: null,
+ };
+ },
+ methods: {
+ open(config) {
+ this.config = config;
+ this.visible = true;
+ this.$nextTick(() => { this.$refs.modalAutoFocus.focus(); });
+ return new Promise((resolve) => {
+ this.resolve = resolve;
+ });
+ },
+ close(resolve) {
+ this.visible = false;
+ this.resolve(resolve);
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/datepicker.js b/internal/notesium/web/app/datepicker.js
new file mode 100644
index 0000000..6cc2063
--- /dev/null
+++ b/internal/notesium/web/app/datepicker.js
@@ -0,0 +1,145 @@
+var t = `
+
+
+
+
+
+
+ Previous month
+
+
+
+ Today
+
+
+ Next month
+
+
+
+
+
+
+
+
+
+
+`
+
+import Icon from './icon.js'
+import { formatDate } from './dateutils.js';
+export default {
+ props: {
+ dottedDates: { type: Object, default: {} },
+ },
+ emits: ['date-selected', 'date-dblclick'],
+ components: { Icon },
+ data() {
+ return {
+ today: null,
+ selectedDate: null,
+ displayedMonth: null,
+ }
+ },
+ methods: {
+ getDotForType(dateStr, type) {
+ const noteTypes = this.dottedDates[dateStr];
+ if (noteTypes && noteTypes.includes(type)) return '•';
+ return '';
+ },
+ getCalendarDays(year, month) {
+ const startDate = new Date(year, month, 1);
+ const endDate = new Date(year, month + 1, 0); // Last day of the month
+ const days = [];
+
+ // Previous month days
+ let startDayOfWeek = startDate.getDay() - this.$notesiumState.startOfWeek;
+ if (startDayOfWeek < 0) startDayOfWeek += 7;
+ for (let i = startDayOfWeek; i > 0; i--) {
+ const date = new Date(year, month, 1 - i);
+ days.push({
+ date: formatDate(date, '%Y-%m-%d'),
+ day: date.getDate(),
+ });
+ }
+
+ // Current month days
+ for (let day = 1; day <= endDate.getDate(); day++) {
+ const date = new Date(year, month, day);
+ const dateStr = formatDate(date, '%Y-%m-%d');
+ days.push({
+ date: dateStr,
+ day: day,
+ isCurrentMonth: true,
+ isToday: dateStr === this.today,
+ });
+ }
+
+ // Next month days to complete the week
+ let endDayOfWeek = endDate.getDay();
+ let daysToAdd = 6 - ((endDayOfWeek - this.$notesiumState.startOfWeek + 7) % 7);
+ if ((days.length + daysToAdd) == 35) daysToAdd += 7;
+ for (let i = 1; i <= daysToAdd; i++) {
+ const date = new Date(year, month + 1, i);
+ days.push({
+ date: formatDate(date, '%Y-%m-%d'),
+ day: date.getDate(),
+ });
+ }
+
+ return days;
+ },
+ setSelectedDate(dateStr) {
+ this.selectedDate = dateStr;
+ this.$emit('date-selected', this.selectedDate);
+ const dateParts = this.selectedDate.split('-');
+ const selectedYear = parseInt(dateParts[0], 10);
+ const selectedMonth = parseInt(dateParts[1], 10);
+ const displayedMonthDate = new Date(this.displayedMonth);
+ if (selectedYear !== displayedMonthDate.getFullYear() || selectedMonth !== displayedMonthDate.getMonth() + 1) {
+ this.displayedMonth = new Date(selectedYear, selectedMonth - 1, 1);
+ }
+ },
+ changeMonth(increment) {
+ this.displayedMonth = new Date(this.displayedMonth.getFullYear(), this.displayedMonth.getMonth() + increment, 1);
+ },
+ },
+ computed: {
+ formattedMonthYear() {
+ return this.displayedMonth.toLocaleString('default', { month: 'short', year: 'numeric' });
+ },
+ displayedMonthDates() {
+ const year = this.displayedMonth.getFullYear();
+ const month = this.displayedMonth.getMonth(); // getMonth is 0-indexed
+ return this.getCalendarDays(year, month)
+ },
+ sortedDaysOfWeek() {
+ const daysOfWeek = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
+ return [...daysOfWeek.slice(this.$notesiumState.startOfWeek), ...daysOfWeek.slice(0, this.$notesiumState.startOfWeek)];
+ },
+ },
+ created() {
+ this.displayedMonth = new Date();
+ this.today = formatDate(this.displayedMonth, '%Y-%m-%d');
+ },
+ mounted() {
+ this.setSelectedDate(this.today);
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/dateutils.js b/internal/notesium/web/app/dateutils.js
new file mode 100644
index 0000000..817d024
--- /dev/null
+++ b/internal/notesium/web/app/dateutils.js
@@ -0,0 +1,35 @@
+export function formatDate(date, format) {
+ const padZero = (num) => { return num.toString().padStart(2, '0'); }
+ const replacements = {
+ '%Y': date.getFullYear(),
+ '%m': padZero(date.getMonth() + 1),
+ '%d': padZero(date.getDate()),
+ '%H': padZero(date.getHours()),
+ '%M': padZero(date.getMinutes()),
+ '%S': padZero(date.getSeconds()),
+ '%A': date.toLocaleString('en-US', { weekday: 'long' }),
+ '%a': date.toLocaleString('en-US', { weekday: 'short' }),
+ '%b': date.toLocaleString('en-US', { month: 'short' }),
+ '%u': date.getDay() === 0 ? 7 : date.getDay(),
+ '%U': padZero(getSunWeekNum(date)),
+ '%V': padZero(getISOWeekNum(date)),
+ };
+ return format.replace(/%[a-zA-Z]/g, match => replacements[match]);
+}
+
+function getISOWeekNum(date) {
+ const tempDate = new Date(date.getTime());
+ tempDate.setHours(0, 0, 0, 0);
+ tempDate.setDate(tempDate.getDate() + 3 - (tempDate.getDay() + 6) % 7);
+ const week1 = new Date(tempDate.getFullYear(), 0, 4);
+ return 1 + Math.round(((tempDate.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
+}
+
+function getSunWeekNum(date) {
+ const sunday = new Date(date);
+ sunday.setDate(date.getDate() - date.getDay());
+ const firstSunday = new Date(sunday.getFullYear(), 0, 1);
+ if (firstSunday.getDay() !== 0) firstSunday.setMonth(0, 1 + (7 - firstSunday.getDay()) % 7);
+ return 1 + Math.floor((sunday - firstSunday) / 604800000);
+}
+
diff --git a/internal/notesium/web/app/empty.js b/internal/notesium/web/app/empty.js
new file mode 100644
index 0000000..7b8f437
--- /dev/null
+++ b/internal/notesium/web/app/empty.js
@@ -0,0 +1,60 @@
+var t = `
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ emits: ['note-new', 'note-daily', 'finder-open'],
+ data() {
+ return {
+ entries: [
+ {
+ title: 'New note',
+ keybind: 'space n n',
+ icon: 'outline-plus',
+ emit: ['note-new'],
+ },
+ {
+ title: 'Daily note',
+ keybind: 'space n d',
+ icon: 'outline-calendar',
+ emit: ['note-daily'],
+ },
+ {
+ title: 'List notes',
+ keybind: 'space n l',
+ icon: 'mini-bars-three-bottom-left',
+ emit: ['finder-open', '/api/raw/list?color=true&prefix=label&sort=alpha'],
+ },
+ {
+ title: 'Search notes',
+ keybind: 'space n s',
+ icon: 'mini-magnifying-glass',
+ emit: ['finder-open', '/api/raw/lines?color=true&prefix=title'],
+ },
+ {
+ title: 'Graph view',
+ keybind: 'space n g',
+ icon: 'graph',
+ emit: ['graph-open'],
+ },
+ ],
+ }
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/favicon.svg b/internal/notesium/web/app/favicon.svg
new file mode 100644
index 0000000..fe740a4
--- /dev/null
+++ b/internal/notesium/web/app/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/internal/notesium/web/app/finder.js b/internal/notesium/web/app/finder.js
new file mode 100644
index 0000000..811b0e3
--- /dev/null
+++ b/internal/notesium/web/app/finder.js
@@ -0,0 +1,107 @@
+var t = `
+
+`
+import Preview from './preview.js'
+export default {
+ components: { Preview },
+ props: ['uri', 'small', 'initialQuery'],
+ emits: ['finder-selection'],
+ data() {
+ return {
+ query: '',
+ items: [],
+ selected: 0,
+ preview: true,
+ }
+ },
+ methods: {
+ handleSelection(selected) {
+ this.$emit('finder-selection', selected !== null ? this.filteredItems[selected] : null);
+ },
+ selectUp() {
+ if (this.selected !== 0) {
+ this.selected -= 1; this.scrollIntoView(`item${this.selected}`)
+ }
+ },
+ selectDown() {
+ if (this.selected !== this.filteredItems.length - 1) {
+ this.selected += 1; this.scrollIntoView(`item${this.selected}`)
+ }
+ },
+ scrollIntoView(id) {
+ document.getElementById(id).scrollIntoView({ block: 'nearest' });
+ },
+ fetchRaw(uri) {
+ this.query = '';
+ this.selected = 0;
+ fetch(uri)
+ .then(response => response.text())
+ .then(text => {
+ const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/
+ this.items = text.trim().split('\n').map(line => {
+ const matches = PATTERN.exec(line);
+ if (!matches) return null;
+ const Filename = matches[1];
+ const Linenum = parseInt(matches[2], 10);
+ const Colored = matches[3] || '';
+ const Content = matches[4];
+ const SearchStr = Colored ? `${Colored} ${Content}`.toLowerCase() : Content.toLowerCase();
+ return { Filename, Linenum, Colored, Content, SearchStr };
+ }).filter(Boolean);
+ });
+ },
+ },
+ computed: {
+ itemsLength() {
+ return this.items.length;
+ },
+ filteredItems() {
+ this.selected = 0;
+ const maxItems = 300;
+ const queryWords = this.query.toLowerCase().split(' ');
+ return !this.query
+ ? this.items.slice(0, maxItems)
+ : this.items.filter(item => (queryWords.every(queryWord => item.SearchStr.includes(queryWord)))).slice(0, maxItems);
+ },
+ },
+ created() {
+ this.preview = this.small ? false : this.preview;
+ this.fetchRaw(this.uri);
+ this.$nextTick(() => { this.query = this.initialQuery ? this.initialQuery : ''; this.$refs.queryInput.focus(); });
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/graph-d3.js b/internal/notesium/web/app/graph-d3.js
new file mode 100644
index 0000000..e33ae68
--- /dev/null
+++ b/internal/notesium/web/app/graph-d3.js
@@ -0,0 +1,204 @@
+var t = `
+
+`
+
+export default {
+ props: [
+ 'graphData',
+ 'emphasizeNodeIds',
+ 'initialTransform',
+ 'display',
+ 'forces',
+ ],
+ emits: ['title-click'],
+ data() {
+ return {
+ zoomTransform: null,
+ }
+ },
+ methods: {
+ initGraph() {
+ const vm = this;
+ vm.$refs.forcegraph.innerHTML = "";
+
+ const links = this.graphData.links.map(d => Object.create(d));
+ const nodes = this.graphData.nodes.map(d => Object.create(d));
+
+ const svg = d3.select(vm.$refs.forcegraph).append("svg")
+ .style("height", "inherit")
+ .style("width", "inherit")
+ .attr("viewBox", [-140, -180, 320, 360]);
+
+ const simulation = d3.forceSimulation(nodes)
+ .force("link", d3.forceLink(links).id(d => d.id))
+ .force("charge", d3.forceManyBody())
+ .force("collide", d3.forceCollide())
+ .force("center", d3.forceCenter())
+ .force("x", d3.forceX())
+ .force("y", d3.forceY());
+
+ const link = svg.append("g")
+ .classed("link", true)
+ .attr("stroke", "currentColor")
+ .selectAll("line")
+ .data(links)
+ .join("line");
+
+ const node = svg.append("g")
+ .selectAll("circle")
+ .data(nodes)
+ .join("circle")
+ .attr("r", 2)
+ .classed("node", true)
+ .classed("node-label", n => (n.isLabel))
+ .call(drag(simulation));
+
+ const title = svg.append("g")
+ .selectAll("circle")
+ .data(nodes)
+ .enter()
+ .append("text")
+ .classed("title", true)
+ .on("click", function(event, node) { event.stopPropagation(); vm.$emit('title-click', node.id); })
+ .text(node => node.title);
+
+ const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) {
+ svg.selectAll('g').attr('transform', event.transform);
+ if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(event.transform.k);
+ vm.zoomTransform = {
+ k: event.transform.k,
+ x: event.transform.x,
+ y: event.transform.y
+ }
+ });
+ svg.call(zoom);
+
+ if (vm.initialTransform) {
+ const {k, x, y} = vm.initialTransform;
+ const transform = d3.zoomIdentity.translate(x, y).scale(k);
+ svg.selectAll('g').attr('transform', transform);
+ svg.call(zoom.transform, transform);
+ if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(k);
+ }
+
+ simulation.on("tick", () => {
+ link
+ .attr("x1", d => d.source.x)
+ .attr("y1", d => d.source.y)
+ .attr("x2", d => d.target.x)
+ .attr("y2", d => d.target.y);
+ node
+ .attr("cx", d => d.x)
+ .attr("cy", d => d.y);
+ title
+ .attr('x', d => d.x + 4).attr('y', d => d.y);
+ });
+
+ function drag(simulation) {
+ function dragstarted(event, d) {
+ if (!event.active) simulation.alphaTarget(0.3).restart();
+ d.fx = d.x;
+ d.fy = d.y;
+ }
+ function dragged(event, d) {
+ d.fx = event.x;
+ d.fy = event.y;
+ }
+ function dragended(event, d) {
+ if (!event.active) simulation.alphaTarget(0);
+ d.fx = null;
+ d.fy = null;
+ }
+ return d3.drag()
+ .on("start", dragstarted)
+ .on("drag", dragged)
+ .on("end", dragended);
+ }
+
+ function scaleTitlesByZoomLevel(k) {
+ const titleSize = k > 0.9 ? 5 - k : 0;
+ svg.selectAll('.title').transition().style("font-size", titleSize + "px");
+ }
+
+ vm.$watch('display.scaleTitles.value', function(enabled) {
+ const k = enabled ? d3.zoomTransform(svg.node()).k : 1;
+ scaleTitlesByZoomLevel(k);
+ });
+
+ vm.$watch('display.showTitles.value', function(enabled) {
+ svg.selectAll('.title').classed("hidden", !enabled);
+ });
+
+ function emphasizeNodes(nodeIds) {
+ if (nodeIds && nodeIds.length > 0) {
+ const linkedNodeIds = Array.from(new Set(vm.graphData.links
+ .filter(l => nodeIds.includes(l.source) || nodeIds.includes(l.target))
+ .flatMap(l => [l.source, l.target])));
+
+ node.attr("fill-opacity", 0.05);
+ title.attr("fill-opacity", 0.15).attr("font-weight", "normal");
+ link.attr("stroke", "currentColor").attr("stroke-opacity", 0.15);
+
+ node.filter(n => linkedNodeIds.includes(n.id)).attr("fill-opacity", 0.3);
+ title.filter(t => linkedNodeIds.includes(t.id)).attr("fill-opacity", 1);
+
+ node.filter(n => nodeIds.includes(n.id)).attr("fill-opacity", 1);
+ title.filter(t => nodeIds.includes(t.id)).attr("fill-opacity", 1).attr("font-weight", "bold");
+ link.filter(l => nodeIds.includes(l.source.id) || nodeIds.includes(l.target.id)).attr("stroke-opacity", 1);
+
+ } else {
+ node.attr("fill-opacity", 1)
+ title.attr("fill-opacity", 1).attr("font-weight", "normal");
+ link.attr("stroke", "currentColor").attr("stroke-opacity", 1);
+ }
+ }
+
+ vm.$watch('emphasizeNodeIds', nodeIds => emphasizeNodes(nodeIds));
+ if (this.emphasizeNodeIds?.length > 0) emphasizeNodes(this.emphasizeNodeIds);
+
+ vm.$watch('display.dynamicNodeRadius.value', function(enabled) {
+ function getLinkCount(nodeId) {
+ return vm.graphData.links.reduce((count, link) => (link.source === nodeId || link.target === nodeId) ? count + 1 : count, 0);
+ }
+
+ if (enabled) {
+ const totalNodes = vm.graphData.nodes.length;
+ const linkCounts = vm.graphData.nodes.map(n => getLinkCount(n.id));
+ const maxLinks = Math.max(...linkCounts);
+
+ const minBaseRadius = 1;
+ const maxBaseRadius = 5;
+ const baseRadius = Math.max(minBaseRadius, Math.min(maxBaseRadius, 5 - totalNodes / 5));
+
+ const minRadiusIncrement = 0.1;
+ const maxRadiusIncrement = 0.5;
+ const radiusIncrement = Math.max(minRadiusIncrement, Math.min(maxRadiusIncrement, 5 / maxLinks));
+
+ node.attr("r", n => baseRadius + (getLinkCount(n.id) * radiusIncrement));
+ } else {
+ node.attr("r", 2);
+ }
+ });
+
+ vm.$watch('forces.chargeStrength.value', function(value) {
+ simulation.force("charge", d3.forceManyBody().strength(value));
+ simulation.alpha(1).restart();
+ });
+
+ vm.$watch('forces.collideRadius.value', function(value) {
+ simulation.force("collide").radius(value);
+ simulation.alpha(1).restart();
+ });
+
+ vm.$watch('forces.collideStrength.value', function(value) {
+ simulation.force("collide").strength(value);
+ simulation.alpha(1).restart();
+ });
+
+ },
+ },
+ mounted() {
+ this.initGraph();
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/graph-panel.js b/internal/notesium/web/app/graph-panel.js
new file mode 100644
index 0000000..d07b8b1
--- /dev/null
+++ b/internal/notesium/web/app/graph-panel.js
@@ -0,0 +1,158 @@
+var t = `
+
+
+
+
+
+
+ graph view
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ display
+
+
+
+
+
+
+ forces
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Pane from './pane.js'
+import Icon from './icon.js'
+import GraphD3 from './graph-d3.js'
+export default {
+ components: { Pane, Icon, GraphD3 },
+ props: ['activeTabId', 'lastSave'],
+ emits: ['note-open'],
+ data() {
+ return {
+ graphData: null,
+ initialTransform: null,
+ query: '',
+ showSettings: false,
+ display: {
+ showTitles: { value: true, title: 'show titles' },
+ scaleTitles: { value: true, title: 'auto-scale titles' },
+ dynamicNodeRadius: { value: false, title: 'size nodes per links' },
+ emphasizeActive: { value: true, title: 'emphasize active note' },
+ },
+ forces: {
+ chargeStrength: { value: -30, min: -100, max: 0, step: 1, title: 'repel force' },
+ collideRadius: { value: 1, min: 1, max: 50, step: 1, title: 'collide radius' },
+ collideStrength: { value: 0.5, min: 0, max: 1, step: 0.05, title: 'collide strength' },
+ },
+ }
+ },
+ methods: {
+ fetchGraph() {
+ fetch("/api/notes")
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(response => {
+ let nodes = [];
+ let links = [];
+ const notes = Object.values(response);
+ notes.forEach(note => {
+ nodes.push({ id: note.Filename, title: note.Title, isLabel: note.IsLabel });
+ if (note.OutgoingLinks) {
+ note.OutgoingLinks.forEach(link => {
+ if (link.Title !== '') links.push({ source: note.Filename, target: link.Filename });
+ });
+ }
+ });
+ this.graphData = { nodes, links };
+ })
+ .catch(e => {
+ console.error(e);
+ });
+ },
+ },
+ computed: {
+ emphasizeNodeIds() {
+ if (this.showSettings && this.query) {
+ const queryWords = this.query.toLowerCase().split(' ');
+ return this.graphData.nodes.filter(node => queryWords.every(queryWord => node.title.toLowerCase().includes(queryWord))).map(node => node.id);
+ }
+ return (this.display.emphasizeActive.value && this.activeTabId) ? [this.activeTabId] : null;
+ },
+ },
+ created() {
+ this.fetchGraph();
+ },
+ watch: {
+ 'lastSave': function() {
+ this.initialTransform = this.$refs.forcegraph.zoomTransform;
+ this.graphData = null;
+ this.fetchGraph();
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/graph.js b/internal/notesium/web/app/graph.js
new file mode 100644
index 0000000..2d113e4
--- /dev/null
+++ b/internal/notesium/web/app/graph.js
@@ -0,0 +1,164 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ display
+
+
+
+
+
+
+ forces
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Icon from './icon.js'
+import GraphD3 from './graph-d3.js'
+import Preview from './preview.js'
+export default {
+ components: { Icon, GraphD3, Preview },
+ emits: ['graph-close', 'note-open'],
+ data() {
+ return {
+ query: '',
+ nodes: [],
+ graphData: null,
+ selectedNodeId: '',
+ showSettings: false,
+ showSettingsDisplay: true,
+ showSettingsForces: false,
+ display: {
+ showTitles: { value: true, title: 'show titles' },
+ scaleTitles: { value: true, title: 'auto-scale titles' },
+ dynamicNodeRadius: { value: false, title: 'size nodes per links' },
+ },
+ forces: {
+ chargeStrength: { value: -30, min: -100, max: 0, step: 1, title: 'repel force' },
+ collideRadius: { value: 1, min: 1, max: 50, step: 1, title: 'collide radius' },
+ collideStrength: { value: 0.5, min: 0, max: 1, step: 0.05, title: 'collide strength' },
+ },
+ }
+ },
+ methods: {
+ fetchGraph() {
+ fetch("/api/notes")
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(response => {
+ let nodes = [];
+ let links = [];
+ const notes = Object.values(response);
+ notes.forEach(note => {
+ nodes.push({ id: note.Filename, title: note.Title, isLabel: note.IsLabel });
+ if (note.OutgoingLinks) {
+ note.OutgoingLinks.forEach(link => {
+ if (link.Title !== '') links.push({ source: note.Filename, target: link.Filename });
+ });
+ }
+ });
+ this.nodes = nodes;
+ this.graphData = { nodes, links };
+ })
+ .catch(e => {
+ console.error(e);
+ });
+ },
+ },
+ computed: {
+ emphasizeNodeIds() {
+ if (this.query) {
+ const queryWords = this.query.toLowerCase().split(' ');
+ return this.nodes.filter(node => queryWords.every(queryWord => node.title.toLowerCase().includes(queryWord))).map(node => node.id);
+ }
+ return this.selectedNodeId ? [this.selectedNodeId] : [];
+ }
+ },
+ mounted() {
+ this.fetchGraph();
+ },
+ created() {
+ this.$nextTick(() => { this.$refs.queryInput.focus(); });
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/icon.js b/internal/notesium/web/app/icon.js
new file mode 100644
index 0000000..83f0b19
--- /dev/null
+++ b/internal/notesium/web/app/icon.js
@@ -0,0 +1,116 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+export default {
+ props: ['name', 'size'],
+ data() {
+ return {
+ }
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/index.html b/internal/notesium/web/app/index.html
new file mode 100644
index 0000000..70de64e
--- /dev/null
+++ b/internal/notesium/web/app/index.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+ Notesium Web
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/notesium/web/app/link-tree.js b/internal/notesium/web/app/link-tree.js
new file mode 100644
index 0000000..c8f71e6
--- /dev/null
+++ b/internal/notesium/web/app/link-tree.js
@@ -0,0 +1,58 @@
+var t = `
+
+
+
+
+ $emit('note-open', ...args)" />
+
+
+`
+
+export default {
+ name: 'LinkTree',
+ props: ['filename', 'title', 'linenum', 'direction'],
+ emits: ['note-open'],
+ data() {
+ return {
+ expanded: false,
+ children: [],
+ }
+ },
+ methods: {
+ toggle() {
+ this.expanded = !this.expanded;
+ if (this.expanded && this.children.length === 0) {
+ this.fetchChildren();
+ }
+ },
+ fetchChildren() {
+ fetch('/api/raw/links?color=true&filename=' + this.filename)
+ .then(response => response.text())
+ .then(text => {
+ const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/
+ this.children = text.trim().split('\n').map(line => {
+ const matches = PATTERN.exec(line);
+ if (!matches) return null;
+ const Filename = matches[1];
+ const Linenum = parseInt(matches[2], 10);
+ const Colored = matches[3] || '';
+ const Content = matches[4];
+ return { title: Content, filename: Filename, linenum: (Colored == 'outgoing') ? 1 : Linenum, direction: Colored };
+ }).filter(Boolean);
+ });
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/make.sh b/internal/notesium/web/app/make.sh
new file mode 100755
index 0000000..e430cef
--- /dev/null
+++ b/internal/notesium/web/app/make.sh
@@ -0,0 +1,80 @@
+#!/bin/bash -e
+
+fatal() { echo "Fatal: $*" 1>&2; exit 1; }
+
+usage() {
+cat</dev/null || fatal "curl not found"
+ command -v sha256sum >/dev/null || fatal "sha256sum not found"
+
+ while IFS=' ' read -r HASH SRC; do
+ local DST=".vendor/$(basename $SRC)"
+ _vendor_get_verify "$SRC" "$DST" "$HASH"
+ case "$DST" in
+ *.js) cat "$DST" >> vendor.js ;;
+ *.css) cat "$DST" >> vendor.css ;;
+ esac
+ done < <(_vendor_files)
+ sha256sum vendor.js
+ sha256sum vendor.css
+}
+
+_tailwind() {
+ # tailwindcss v3.1.6
+ OPTS="$@"
+ command -v tailwindcss >/dev/null || fatal "tailwindcss not found"
+ [ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found"
+ [ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found"
+ tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css
+}
+
+main() {
+ cd $(dirname $(realpath $0))
+ case $1 in
+ ""|-h|--help|help) usage;;
+ all) _vendor; _tailwind;;
+ vendor) _vendor;;
+ tailwind) shift; _tailwind $@;;
+ *) fatal "unrecognized command: $1";;
+ esac
+}
+
+main "$@"
diff --git a/internal/notesium/web/app/nav-tabs.js b/internal/notesium/web/app/nav-tabs.js
new file mode 100644
index 0000000..2dfa0fc
--- /dev/null
+++ b/internal/notesium/web/app/nav-tabs.js
@@ -0,0 +1,122 @@
+var t = `
+
+
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ props: ['tabs', 'activeTabId', 'previousTabId', 'notes'],
+ emits: ['tab-activate', 'tab-move', 'tab-close', 'note-close'],
+ data() {
+ return {
+ dragIndex: null,
+ dragOverEnabled: true,
+ }
+ },
+ methods: {
+ onDragStart(index) {
+ this.$emit('tab-activate', this.tabs[index].id)
+ this.dragIndex = index;
+ },
+ onDragOver(overIndex) {
+ if (!this.dragOverEnabled) return;
+ if (this.dragIndex === null || this.dragIndex === overIndex) return;
+
+ this.$emit('tab-move', this.tabs[this.dragIndex].id, overIndex);
+ this.dragIndex = overIndex;
+
+ this.dragOverEnabled = false;
+ setTimeout(() => { this.dragOverEnabled = true; }, 300);
+ },
+ onDrop() {
+ this.dragIndex = null;
+ },
+ onDragEnd() {
+ this.dragIndex = null;
+ },
+ isActive(tabId) {
+ return this.activeTabId == tabId;
+ },
+ handleClose(tabId, tabType) {
+ if (tabType === 'note') {
+ this.$emit('note-close', tabId);
+ return;
+ }
+ this.$emit('tab-close', tabId);
+ },
+ handleKeyPress(event) {
+ if (event.target.tagName !== 'BODY') return
+
+ if (event.ctrlKey && event.code == 'Digit6') {
+ this.previousTabId && this.$emit('tab-activate', this.previousTabId);
+ event.preventDefault();
+ return;
+ }
+
+ if (event.ctrlKey && (event.code == 'KeyH' || event.code == 'KeyL')) {
+ const index = this.tabs.findIndex(t => t.id === this.activeTabId);
+ if (index === -1) return;
+ const movement = event.code === 'KeyL' ? 1 : -1;
+ const newIndex = (index + movement + this.tabs.length) % this.tabs.length;
+ this.$emit('tab-activate', this.tabs[newIndex].id);
+ event.preventDefault();
+ return;
+ }
+ },
+ },
+ computed: {
+ getTabs() {
+ return this.tabs.map(tab => {
+ if (tab.type === 'note') {
+ const note = this.notes.find(n => n.Filename === tab.id);
+ return {
+ id: tab.id,
+ type: tab.type,
+ title: note.Title,
+ titleHover: `${note.Title} (${note.Filename})`,
+ isModified: note.isModified || false,
+ };
+ }
+ });
+ },
+ },
+ mounted() {
+ document.addEventListener('keydown', this.handleKeyPress);
+ },
+ beforeDestroy() {
+ document.removeEventListener('keydown', this.handleKeyPress);
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/note-sidebar.js b/internal/notesium/web/app/note-sidebar.js
new file mode 100644
index 0000000..7831463
--- /dev/null
+++ b/internal/notesium/web/app/note-sidebar.js
@@ -0,0 +1,137 @@
+var t = `
+
+
+
+
+
Save
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lines
+
+
+
+ Words
+
+
+
+ Chars
+
+
+
+
+
+
+
+
+ Modified
+
+
+
+
+
+
+ Created
+
+
+
+
+
+
+
+
+
Links incoming
+
+
+
$emit('note-open', ...args)" />
+
+
+
Links outgoing
+
+
+ $emit('note-open', ...args)" />
+
+
+
+
+
+
+`
+
+import Pane from './pane.js'
+import Icon from './icon.js'
+import LinkTree from './link-tree.js'
+import { formatDate } from './dateutils.js';
+export default {
+ components: { Pane, Icon, LinkTree },
+ props: ['note'],
+ emits: ['note-open', 'note-save', 'note-delete', 'finder-open'],
+ methods: {
+ formattedDate(dateStr) {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return formatDate(date, '%b %d %Y at %H:%M');
+ },
+ },
+ computed: {
+ sortedIncomingLinks() {
+ return this.note.IncomingLinks?.sort((a, b) => a.Title.localeCompare(b.Title)) || [];
+ },
+ countIncomingLinks() {
+ return this.note.IncomingLinks?.length || 0;
+ },
+ existingOutgoingLinks() {
+ return this.note.OutgoingLinks?.filter(l => l.Title !== '') || [];
+ },
+ danglingOutgoingLinks() {
+ return this.note.OutgoingLinks?.filter(l => l.Title == '') || [];
+ },
+ countOutgoingLinks() {
+ return this.note.OutgoingLinks?.length || 0;
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/note-statusbar.js b/internal/notesium/web/app/note-statusbar.js
new file mode 100644
index 0000000..ba6af4e
--- /dev/null
+++ b/internal/notesium/web/app/note-statusbar.js
@@ -0,0 +1,64 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{note.IncomingLinks?.length || 0}}↙
+
+
+ {{note.OutgoingLinks?.length || 0}}↗
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ props: ['note', 'vimMode', 'hasFocus' ],
+ emits: ['note-delete', 'finder-open'],
+ computed: {
+ vimModeText() {
+ const modeText = { 'visual-linewise': 'v-line', 'visual-blockwise': 'v-block' };
+ return modeText[`${this.vimMode.mode}-${this.vimMode.subMode}`] || this.vimMode.mode;
+ },
+ vimModeCls() {
+ const modeCls = { normal: 'bg-slate-500', insert: 'bg-yellow-500', visual: 'bg-pink-500', replace: 'bg-red-500', command: 'bg-sky-500' };
+ return modeCls[this.vimMode.mode]
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/note.js b/internal/notesium/web/app/note.js
new file mode 100644
index 0000000..fa0d9c1
--- /dev/null
+++ b/internal/notesium/web/app/note.js
@@ -0,0 +1,333 @@
+var t = `
+
+
+
+
$emit('note-delete', ...args)"
+ @finder-open="(...args) => $emit('finder-open', ...args)" />
+
+
+
+ Save
+
+
+
+
+
+
$emit('note-open', ...args)"
+ @note-delete="(...args) => $emit('note-delete', ...args)"
+ @finder-open="(...args) => $emit('finder-open', ...args)" />
+
+
+
+`
+
+import * as Table from './cm-table.js';
+import NoteSidebar from './note-sidebar.js'
+import NoteStatusbar from './note-statusbar.js'
+import Finder from './finder.js'
+import Icon from './icon.js'
+export default {
+ components: { NoteSidebar, NoteStatusbar, Finder, Icon },
+ props: ['note', 'activeTabId'],
+ emits: ['note-open', 'note-close', 'note-save', 'note-delete', 'finder-open'],
+ data() {
+ return {
+ hasFocus: false,
+ vimMode: null,
+ fatCursor: false,
+ showFinder: false,
+ selectedLines: [],
+ }
+ },
+ methods: {
+ simulateDoubleLeftBracket() {
+ if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return CodeMirror.Pass;
+ const cursorPos = this.cm.getCursor();
+ this.cm.replaceRange('[', cursorPos, cursorPos);
+ this.showFinder = true;
+ },
+ handleLeftBracket(e) {
+ if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return;
+ e.preventDefault();
+
+ const cursorPos = this.cm.getCursor();
+ const startPos = { line: cursorPos.line, ch: cursorPos.ch - 1 };
+ const prevChar = this.cm.getRange(startPos, cursorPos);
+
+ const now = Date.now();
+ const timeSinceLastPress = now - (this.lastBracketPressTime || 0);
+ const threshold = 2000;
+
+ if (prevChar === '[' && timeSinceLastPress < threshold) {
+ this.showFinder = true;
+ } else {
+ this.cm.replaceRange('[', cursorPos, cursorPos);
+ this.lastBracketPressTime = now;
+
+ this.fatCursor = true;
+ const restoreCursor = () => { this.fatCursor = false; this.cm.off('keydown', restoreCursor); };
+ this.cm.on('keydown', restoreCursor);
+ setTimeout(() => { restoreCursor(); }, threshold);
+ }
+ },
+ handleFinderSelection(value) {
+ this.showFinder = false;
+
+ const cursorPos = this.cm.getCursor();
+ const startPos = { line: cursorPos.line, ch: cursorPos.ch - 1 };
+ let formattedLink = '';
+ if (value) formattedLink = `[${value.Content}](${value.Filename})`;
+ this.cm.replaceRange(formattedLink, startPos, cursorPos);
+
+ this.$nextTick(() => {
+ this.cm.focus();
+ this.cm.refresh();
+ if (this.$notesiumState.editorVimMode) {
+ CodeMirror.Vim.handleKey(this.cm, "a");
+ }
+ });
+ },
+ handleSave() {
+ if (this.note.isModified) {
+ const timestamp = this.note.ghost ? this.note.Ctime : this.note.Mtime;
+ this.$emit('note-save', this.note.Filename, this.cm.getValue(), timestamp, this.note.ghost);
+ }
+ if (this.$notesiumState.editorVimMode) {
+ CodeMirror.Vim.exitInsertMode(this.cm)
+ }
+ },
+ handleTab() {
+ if (this.$notesiumState.editorVimMode && this.vimMode.mode !== 'insert' ) return;
+ if (this.cm.somethingSelected()) return CodeMirror.Pass;
+ if (Table.isCursorInTable(this.cm)) {
+ Table.formatTableAndAdvance(this.cm, this.$notesiumState.editorConcealFormatting);
+ } else {
+ this.cm.execCommand('insertSoftTab');
+ }
+ },
+ handleBackspace() {
+ if (this.cm.somethingSelected()) return CodeMirror.Pass;
+ const cursorPos = this.cm.getCursor();
+ const indentUnit = this.cm.getOption('indentUnit');
+ const spacesForIndentUnit = ' '.repeat(indentUnit);
+ const checkFrom = {line: cursorPos.line, ch: Math.max(0, cursorPos.ch - indentUnit)};
+ if (this.cm.getRange(checkFrom, cursorPos) === spacesForIndentUnit) {
+ this.cm.replaceRange('', checkFrom, cursorPos);
+ } else {
+ return CodeMirror.Pass;
+ }
+ },
+ handleEsc() {
+ if (this.$notesiumState.editorVimMode) {
+ CodeMirror.Vim.handleEx(this.cm, 'nohlsearch');
+ return CodeMirror.Pass;
+ }
+ this.cm.display.input.blur();
+ document.body.focus();
+ },
+ handleEditorVimMode() {
+ if (this.$notesiumState.editorVimMode) {
+ this.cm.setOption("keyMap", "vim");
+ this.cm.on('vim-mode-change', (e) => { this.vimMode = e; });
+ this.cm.on('vim-keypress', (key) => { if (':/?'.includes(key)) this.vimMode = { mode: 'command' }; });
+ this.cm.focus();
+ } else {
+ this.cm.setOption("keyMap", "default");
+ this.cm.off('vim-mode-change');
+ this.cm.off('vim-keypress');
+ this.vimMode = null;
+ }
+ },
+ handleEditorFoldGutter() {
+ if (this.$notesiumState.editorFoldGutter) {
+ this.cm.setOption("gutters", ["CodeMirror-foldgutter"]);
+ this.cm.setOption("foldGutter", true);
+ } else {
+ this.cm.setOption("gutters", []);
+ this.cm.setOption("foldGutter", false);
+ }
+ },
+ foldWidget(from, to) {
+ var header = document.createElement("span");
+ header.appendChild(document.createTextNode(this.cm.getLine(from.line).trim()));
+ header.className = "cm-foldmarker-header";
+
+ var lines = document.createElement("span");
+ lines.appendChild(document.createTextNode(`[${to.line - from.line} lines]`));
+ lines.className = "cm-foldmarker-lines";
+
+ var widget = document.createElement("span");
+ widget.appendChild(header);
+ widget.appendChild(lines);
+ widget.className = "cm-foldmarker";
+ return widget;
+ },
+ lineNumberHL(linenum) {
+ if (!Number.isInteger(linenum) || linenum === undefined) return;
+ this.$nextTick(() => {
+ this.cm.setOption("styleActiveLine", true);
+ this.cm.setCursor({line: linenum - 1});
+ this.note.Linenum = undefined;
+ });
+ },
+ handleKeyPress(event) {
+ if (event.target.tagName !== 'BODY' || this.note.Filename !== this.activeTabId) return;
+ if (event.ctrlKey && event.code === 'KeyS') {
+ this.handleSave();
+ event.preventDefault();
+ }
+ if (event.code === 'Tab') {
+ this.$nextTick(() => { this.cm.focus(); });
+ event.preventDefault();
+ }
+ },
+ },
+ mounted() {
+ this.cm = new CodeMirror(this.$refs.codemirror, {
+ value: this.note.Content,
+ placeholder: '# title',
+ lineNumbers: false,
+ lineWrapping: this.$notesiumState.editorLineWrapping,
+ styleActiveLine: false,
+ foldOptions: {
+ rangeFinder: CodeMirror.fold.markdown,
+ markdownIncludeHeader: true,
+ widget: this.foldWidget,
+ inclusiveRight: true,
+ clearOnEnter: false,
+ scanUp: true,
+ },
+ tabSize: 4,
+ indentUnit: 4,
+ theme: 'notesium-light',
+ mode: {
+ name: "gfm",
+ highlightFormatting: true,
+ },
+ extraKeys: {
+ "Esc": this.handleEsc,
+ "Ctrl-S": this.handleSave,
+ "Tab": this.handleTab,
+ "Backspace": this.handleBackspace,
+ "Ctrl-Enter": function(cm) { return cm.execCommand('toggleFold'); },
+ "Shift-Tab": function(cm) { return Table.navigateTable(cm, 'left'); },
+ "Alt-Up": function(cm) { return Table.navigateTable(cm, 'up'); },
+ "Alt-Down": function(cm) { return Table.navigateTable(cm, 'down'); },
+ "Alt-Left": function(cm) { return Table.navigateTable(cm, 'left'); },
+ "Alt-Right": function(cm) { return Table.navigateTable(cm, 'right'); },
+ "Alt-K": this.simulateDoubleLeftBracket,
+ },
+ });
+
+ if (Number.isInteger(this.note.Linenum) && this.note.Linenum > 1) {
+ this.lineNumberHL(this.note.Linenum);
+ }
+
+ this.cm.save = () => { this.handleSave(); }
+ this.cm.quit = (confirmIfModified) => { this.$emit('note-close', this.note.Filename, confirmIfModified); }
+ this.cm.writequit = () => {
+ if (!this.note.isModified) {
+ this.$emit('note-close', this.note.Filename);
+ return;
+ }
+ const currentMtime = this.note.Mtime;
+ const closeWhenSaved = (attempts, interval) => {
+ if (attempts <= 0) return;
+ setTimeout(() => {
+ (currentMtime !== this.note.Mtime) ? this.$emit('note-close', this.note.Filename) : closeWhenSaved(attempts - 1, interval * 2);
+ }, interval);
+ };
+ this.handleSave();
+ closeWhenSaved(5, 100);
+ }
+ this.cm.openlink = (link) => {
+ const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
+ const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
+ (isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
+ }
+
+ this.cm.on('focus', (cm, e) => {
+ if (this.$notesiumState.editorVimMode) CodeMirror.Vim.exitInsertMode(this.cm);
+ this.cm.setOption("styleActiveLine", true);
+ this.hasFocus = true;
+ });
+ this.cm.on('blur', (cm, e) => {
+ if (this.vimMode?.mode == 'command') return;
+ this.vimMode = null;
+ this.cm.setOption("styleActiveLine", false);
+ this.hasFocus = false;
+ });
+ this.cm.on('changes', (cm, changes) => {
+ this.note.isModified = !cm.doc.isClean();
+ });
+ this.cm.on('cursorActivity', (cm, e) => {
+ if (!this.$notesiumState.editorConcealFormatting) return;
+ this.selectedLines.forEach(line => {
+ cm.removeLineClass(line, 'wrap', 'CodeMirror-selectedline');
+ });
+ this.selectedLines = [];
+ if (cm.somethingSelected()) {
+ const from = cm.getCursor("from").line;
+ const to = cm.getCursor("to").line;
+ for (let line = from; line <= to; line++) {
+ cm.addLineClass(line, 'wrap', 'CodeMirror-selectedline');
+ this.selectedLines.push(line);
+ }
+ }
+ });
+
+ this.cm.on('mousedown', (cm, e) => {
+ let el = e.composedPath()[0];
+ if (el.classList.contains('cm-link') || el.classList.contains('cm-url')) {
+ const getNextNSibling = (element, n) => { for (; n > 0 && element; n--, element = element.nextElementSibling); return element; };
+
+ if (el.classList.contains('cm-formatting')) {
+ switch (el.textContent) {
+ case '[': el = getNextNSibling(el, 4); break;
+ case ']': el = getNextNSibling(el, 2); break;
+ case '(': el = getNextNSibling(el, 1); break;
+ case ')': el = el.previousElementSibling; break;
+ default: return;
+ }
+ if (!el?.classList.contains('cm-url')) return;
+ }
+
+ if (el.classList.contains('cm-link')) {
+ const potentialUrlElement = getNextNSibling(el, 3);
+ el = potentialUrlElement?.classList.contains('cm-url') ? potentialUrlElement : el;
+ }
+
+ const link = el.textContent;
+ const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
+ const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
+ (isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
+ e.preventDefault();
+ }
+ });
+
+ this.handleEditorVimMode();
+ this.handleEditorFoldGutter();
+ document.addEventListener('keydown', this.handleKeyPress);
+ },
+ beforeDestroy() {
+ document.removeEventListener('keydown', this.handleKeyPress);
+ },
+ watch: {
+ 'activeTabId': function(newVal) { if (this.$notesiumState.editorVimMode && this.note.Filename == newVal) this.$nextTick(() => { this.cm.focus(); }); },
+ 'note.Linenum': function(newVal) { this.lineNumberHL(newVal); if (this.$notesiumState.editorVimMode) this.$nextTick(() => { this.cm.focus(); }); },
+ 'note.Mtime': function() { this.cm.doc.markClean(); },
+ '$notesiumState.editorLineWrapping': function(newVal) { this.cm.setOption("lineWrapping", newVal); },
+ '$notesiumState.editorVimMode': function() { this.handleEditorVimMode(); },
+ '$notesiumState.editorFoldGutter': function() { this.handleEditorFoldGutter(); },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/pane.js b/internal/notesium/web/app/pane.js
new file mode 100644
index 0000000..6d47984
--- /dev/null
+++ b/internal/notesium/web/app/pane.js
@@ -0,0 +1,71 @@
+var t = `
+
+`
+
+export default {
+ props: {
+ name: { type: String },
+ defaultWidth: { type: Number, default: 200 },
+ minWidth: { type: Number, default: 100 },
+ direction: { type: String, default: "right" },
+ },
+ data() {
+ return {
+ paneWidth: null,
+ maxWidth: null,
+ startResizeClientX: null,
+ startResizePaneWidth: null,
+ resizing: false,
+ }
+ },
+ methods: {
+ startResize(event) {
+ this.startResizeClientX = event.clientX;
+ this.startResizePaneWidth = this.paneWidth;
+ this.maxWidth = this.$el.parentElement.offsetWidth - 50;
+ this.resizing = true;
+ event.preventDefault();
+ document.addEventListener('mousemove', this.doResize);
+ document.addEventListener('mouseup', this.stopResize);
+ },
+ doResize(event) {
+ let draggedDistance = event.clientX - this.startResizeClientX;
+ if (this.direction === 'left') draggedDistance = -draggedDistance;
+ const newWidth = this.startResizePaneWidth + draggedDistance;
+ if (newWidth <= this.maxWidth && newWidth >= this.minWidth ) this.paneWidth = newWidth;
+ },
+ stopResize() {
+ this.resizing = false;
+ this.savePreferredWidth();
+ document.removeEventListener('mousemove', this.doResize);
+ document.removeEventListener('mouseup', this.stopResize);
+ },
+ setDefaultWidth() {
+ this.paneWidth = this.defaultWidth;
+ this.savePreferredWidth()
+ },
+ savePreferredWidth() {
+ const key = `${this.name}Width`;
+ this.$notesiumState[key] = this.paneWidth;
+ },
+ loadPreferredWidth() {
+ const key = `${this.name}Width`;
+ this.paneWidth = parseInt(this.$notesiumState[key], 10) || this.defaultWidth;
+ },
+ },
+ mounted() {
+ this.loadPreferredWidth();
+ },
+ template: t
+}
+
diff --git a/internal/notesium/web/app/periodic.js b/internal/notesium/web/app/periodic.js
new file mode 100644
index 0000000..49b95e7
--- /dev/null
+++ b/internal/notesium/web/app/periodic.js
@@ -0,0 +1,62 @@
+var t = `
+
+
+
+
periodicNoteDate = date" />
+
+
+ Weekly note
+
+
+ Daily note
+
+
+
+
+`
+
+import DatePicker from './datepicker.js'
+export default {
+ components: { DatePicker },
+ emits: ['note-daily', 'note-weekly', 'periodic-close'],
+ data() {
+ return {
+ showDatePicker: false,
+ periodicNoteDate: null,
+ periodicNoteDates: {},
+ }
+ },
+ methods: {
+ fetchPeriodicNoteDates() {
+ fetch('/api/raw/list?prefix=ctime&date=2006-01-02T15:04:05')
+ .then(response => response.text())
+ .then(text => {
+ const dates = text.split('\n').reduce((acc, line) => {
+ const parts = line.split(' ');
+ if (parts.length > 1) {
+ const date = parts[1].split('T')[0];
+ const time = parts[1].split('T')[1];
+ if (time === '00:00:00') {
+ if (!acc[date]) acc[date] = [];
+ if (!acc[date].includes('daily')) acc[date].push('daily');
+ } else if (time === '00:00:01') {
+ if (!acc[date]) acc[date] = [];
+ if (!acc[date].includes('weekly')) acc[date].push('weekly');
+ }
+ }
+ return acc;
+ }, {});
+ this.periodicNoteDates = dates;
+ this.showDatePicker = true;
+ });
+ },
+ },
+ created() {
+ this.fetchPeriodicNoteDates();
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/preview.js b/internal/notesium/web/app/preview.js
new file mode 100644
index 0000000..afdee39
--- /dev/null
+++ b/internal/notesium/web/app/preview.js
@@ -0,0 +1,82 @@
+var t = `
+
+`
+
+export default {
+ props: ['filename', 'lineNumber', 'clickableLinks', 'appendIncomingLinks'],
+ emits: ['note-open'],
+ methods: {
+ fetchNote() {
+ fetch("/api/notes/" + this.filename)
+ .then(response => response.json())
+ .then(note => {
+ if (this.appendIncomingLinks && note.IncomingLinks?.length) {
+ const sortedIncomingLinks = note.IncomingLinks.sort((a, b) => a.Title.localeCompare(b.Title));
+ const linksMd = sortedIncomingLinks.map(link => `- [${link.Title}](${link.Filename})`).join('\n');
+ this.cm.setValue(`${note.Content.replace(/\n+$/, '')}\n\n---\n\n**Incoming links**\n\n${linksMd}`);
+ } else {
+ this.cm.setValue(note.Content);
+ this.lineNumberHL();
+ }
+ });
+ },
+ lineNumberHL() {
+ if (!Number.isInteger(this.lineNumber) || this.lineNumber === undefined) return;
+ this.$nextTick(() => {
+ this.cm.setOption("styleActiveLine", true);
+ this.cm.setCursor({line: this.lineNumber - 1, ch: 0});
+ });
+ },
+ },
+ mounted() {
+ this.cm = new CodeMirror(this.$refs.preview, {
+ value: '',
+ readOnly: true,
+ styleActiveLine: false,
+ lineWrapping: this.$notesiumState.editorLineWrapping,
+ theme: 'notesium-light',
+ mode: {
+ name: "gfm",
+ highlightFormatting: true,
+ },
+ });
+
+ if (this.clickableLinks) {
+ this.cm.on('mousedown', (cm, e) => {
+ let el = e.composedPath()[0];
+ if (el.classList.contains('cm-link') || el.classList.contains('cm-url')) {
+ const getNextNSibling = (element, n) => { for (; n > 0 && element; n--, element = element.nextElementSibling); return element; };
+
+ if (el.classList.contains('cm-formatting')) {
+ switch (el.textContent) {
+ case '[': el = getNextNSibling(el, 4); break;
+ case ']': el = getNextNSibling(el, 2); break;
+ case '(': el = getNextNSibling(el, 1); break;
+ case ')': el = el.previousElementSibling; break;
+ default: return;
+ }
+ if (!el?.classList.contains('cm-url')) return;
+ }
+
+ if (el.classList.contains('cm-link')) {
+ const potentialUrlElement = getNextNSibling(el, 3);
+ el = potentialUrlElement?.classList.contains('cm-url') ? potentialUrlElement : el;
+ }
+
+ const link = el.textContent;
+ const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link);
+ const hasProtocol = /^[a-zA-Z]+:\/\//.test(link);
+ (isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank');
+ e.preventDefault();
+ }
+ });
+ }
+
+ this.fetchNote();
+ },
+ watch: {
+ filename: function() { this.fetchNote(); },
+ lineNumber: function() { this.lineNumberHL(); },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/ribbon.js b/internal/notesium/web/app/ribbon.js
new file mode 100644
index 0000000..a52ee8c
--- /dev/null
+++ b/internal/notesium/web/app/ribbon.js
@@ -0,0 +1,84 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ props: ['versionCheck', 'showPeriodic'],
+ emits: ['note-new', 'finder-open', 'periodic-open', 'settings-open', 'graph-open'],
+ computed: {
+ updateAvailable() {
+ return this.versionCheck.comparison == '-1';
+ },
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/settings-about.js b/internal/notesium/web/app/settings-about.js
new file mode 100644
index 0000000..4d68239
--- /dev/null
+++ b/internal/notesium/web/app/settings-about.js
@@ -0,0 +1,157 @@
+var t = `
+
+`
+
+import Icon from './icon.js'
+import { formatDate } from './dateutils.js';
+export default {
+ components: { Icon },
+ emits: ['version-check'],
+ props: ['versionCheck'],
+ data() {
+ return {
+ runtime: {},
+ }
+ },
+ methods: {
+ getRuntime() {
+ fetch("/api/runtime")
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(response => { this.runtime = response; })
+ .catch(e => { console.error(e); });
+ },
+ formattedDate(date) {
+ if (date) return formatDate(date, '%Y-%m-%d %H:%M:%S');
+ },
+ },
+ computed: {
+ issueUrl() {
+ const url = "https://github.com/alonswartz/notesium/issues/new";
+ if (Object.keys(this.runtime).length === 0) return url
+ let body
+ body = "```\n";
+ body += `version:${this.runtime.version}\n`;
+ body += `gitversion:${this.runtime.build.gitversion}\n`;
+ body += `buildtime:${this.runtime.build.buildtime}\n`;
+ body += `platform:${this.runtime.platform}\n`;
+ body += "```\n\n";
+ const params = new URLSearchParams({ body: body });
+ return `${url}?${params.toString()}`;
+ },
+ },
+ created() {
+ this.getRuntime();
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/settings-editor.js b/internal/notesium/web/app/settings-editor.js
new file mode 100644
index 0000000..3ff9411
--- /dev/null
+++ b/internal/notesium/web/app/settings-editor.js
@@ -0,0 +1,160 @@
+var t = `
+
+`
+
+import Icon from './icon.js'
+export default {
+ components: { Icon },
+ data() {
+ return {
+ settings: {
+ name: 'settings',
+ title: 'Settings',
+ entries: [
+ {name: 'editorVimMode', title: 'Vim mode'},
+ {name: 'editorLineWrapping', title: 'Line wrapping'},
+ {name: 'editorConcealFormatting', title: 'Conceal formatting'},
+ {name: 'editorFoldGutter', title: 'Fold gutter'},
+ ]
+ },
+ keymaps: [
+ {
+ name: 'default',
+ title: 'Default mode',
+ entries: [
+ ['Tab', 'Enter editing mode (focus active note)', 'none'],
+ ['C-s', 'Save note', 'all'],
+ ['[[', 'Insert selected note link via Finder (mtime sorted)', 'edit'],
+ ['Alt-k', 'Insert selected note link via Finder (mtime sorted)', 'edit'],
+ ['Shift-Tab', 'Auto-indent current line or selection', 'edit'],
+ ['C-]', 'Indent current line or selection', 'edit'],
+ ['C-[', 'Dedent current line or selection', 'edit'],
+ ['C-Enter', 'Toggle section fold', 'edit'],
+ ['Esc', 'Exit editing mode (unfocus)', 'edit'],
+ ]
+ },
+ {
+ name: 'vim',
+ title: 'Vim mode',
+ info: true,
+ entries: [
+ ['Tab', 'Enter normal mode (focus active note)', 'none'],
+ ['C-s', 'Save note and set normal mode', 'all'],
+ ['C-h C-l C-6', 'Note tab keybinds passthrough', 'all'],
+ ['space n ', 'Global keybinds passthrough', 'normal'],
+ ['ge | gx', 'Open link under cursor', 'normal'],
+ ['z | C-Enter', 'Fold, unfold, toggle sections', 'normal'],
+ ['[[', 'Insert selected note link via Finder (mtime sorted)', 'insert'],
+ ['Alt-k', 'Insert selected note link via Finder (mtime sorted)', 'insert'],
+ [':set [no]wrap', 'Set line wrapping', 'command'],
+ [':set [no]conceal', 'Set conceal formatting', 'command'],
+ [':set [no]fold', 'Set fold gutter', 'command'],
+ ]
+ },
+ {
+ name: 'table',
+ title: 'Table formatting and navigation',
+ info: true,
+ entries: [
+ ['Tab', 'Format table and advance column (right)', 'table'],
+ ['Shift-Tab', 'Navigate to previous column (left)', 'table'],
+ ['Alt-Arrow', 'Navigate rows and columns', 'table'],
+ ]
+ },
+ ],
+ }
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/settings-keybinds.js b/internal/notesium/web/app/settings-keybinds.js
new file mode 100644
index 0000000..57ba3dc
--- /dev/null
+++ b/internal/notesium/web/app/settings-keybinds.js
@@ -0,0 +1,65 @@
+var t = `
+
+`
+
+export default {
+ data() {
+ return {
+ sections: [
+ {
+ name: 'global',
+ title: 'Global',
+ entries: [
+ ['space n n', 'Open new note for editing'],
+ ['space n d', 'Open new or existing daily note'],
+ ['space n w', 'Open new or existing weekly note'],
+ ['space n l', 'Finder: List with prefixed label, sorted alphabetically'],
+ ['space n c', 'Finder: List with prefixed date created, sorted by ctime'],
+ ['space n m', 'Finder: List with prefixed date modified, sorted by mtime'],
+ ['space n k', 'Finder: Links related notes to active note (or all if none open)'],
+ ['space n s', 'Finder: Full text search across all notes'],
+ ['space n g', 'Open fullscreen force graph view'],
+ ]
+ },
+ {
+ name: 'finder',
+ title: 'Finder',
+ entries: [
+ ['C-p', 'Toggle preview'],
+ ['↓ | C-j', 'Select next entry (down)'],
+ ['↑ | C-k', 'Select previous entry (up)'],
+ ['Enter', 'Submit selected entry'],
+ ['Esc', 'Dismiss finder'],
+ ]
+ },
+ {
+ name: 'tabs',
+ title: 'Note tabs',
+ entries: [
+ ['C-h', 'Switch to note tab on the left of the active note tab'],
+ ['C-l', 'Switch to note tab on the right of the active note tab'],
+ ['C-^ | C-6', 'Switch to previously active tab'],
+ ]
+ },
+ ],
+ }
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/settings-stats.js b/internal/notesium/web/app/settings-stats.js
new file mode 100644
index 0000000..0a9c5b7
--- /dev/null
+++ b/internal/notesium/web/app/settings-stats.js
@@ -0,0 +1,61 @@
+var t = `
+
+`
+
+export default {
+ emits: ['finder-open'],
+ data() {
+ return {
+ counts: {},
+ stats: [
+ ['notes', 'Notes', 'All notes', '/api/raw/list?color=true&prefix=label&sort=alpha'],
+ ['labels', 'Label notes', 'Notes with one-word titles', '/api/raw/list?color=true&labels=true&sort=alpha'],
+ ['orphans', 'Orphan notes', 'Notes without incoming or outgoing links', '/api/raw/list?color=true&orphans=true&sort=alpha'],
+ ['links', 'Links', 'Count of links', '/api/raw/links?color=true'],
+ ['dangling', 'Dangling links', 'Count of broken links', '/api/raw/links?color=true&dangling=true'],
+ ['lines', 'Lines', 'Count of lines spanning all notes', '/api/raw/lines?color=true&prefix=title'],
+ ['words', 'Words', 'Count of words spanning all notes', null],
+ ['chars', 'Characters', 'Count of characters spanning all notes', null],
+ ],
+ }
+ },
+ methods: {
+ getCounts() {
+ fetch('/api/raw/stats')
+ .then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e)))
+ .then(text => {
+ this.counts = text.trim().split('\n').reduce((dict, line) => {
+ const [key, val] = line.split(' '); dict[key] = val;
+ return dict;
+ }, {});
+ })
+ .catch(e => {
+ console.error(e.Error);
+ });
+ },
+ },
+ created() {
+ this.getCounts();
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/settings.js b/internal/notesium/web/app/settings.js
new file mode 100644
index 0000000..c4cf18a
--- /dev/null
+++ b/internal/notesium/web/app/settings.js
@@ -0,0 +1,54 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
$emit('finder-open', ...args)" />
+ $emit('version-check', ...args)" :versionCheck=versionCheck />
+
+
+
+
+
+
+`
+
+import KeyBinds from './settings-keybinds.js'
+import Editor from './settings-editor.js'
+import About from './settings-about.js'
+import Stats from './settings-stats.js'
+export default {
+ components: { KeyBinds, Editor, About, Stats },
+ props: ['versionCheck'],
+ emits: ['settings-close', 'version-check', 'finder-open'],
+ data() {
+ return {
+ active: 'keybinds',
+ sections: [
+ ['keybinds', 'Key Bindings'],
+ ['editor', 'Editor'],
+ ['stats', 'Statistics'],
+ ['about', 'About'],
+ ],
+ }
+ },
+ created() {
+ if (this.versionCheck.comparison == '-1') this.active = 'about';
+ },
+ template: t
+}
diff --git a/internal/notesium/web/app/sidepanel.js b/internal/notesium/web/app/sidepanel.js
new file mode 100644
index 0000000..6b9426c
--- /dev/null
+++ b/internal/notesium/web/app/sidepanel.js
@@ -0,0 +1,387 @@
+var t = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Title
+
+
+
+ Modified
+
+
+
+ Created
+
+
+
+ Linked notes count
+
+
+
+
+ Compact view
+
+
+
+ Detailed view
+
+
+
+
+ Dark mode
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+import Icon from './icon.js'
+import Pane from './pane.js'
+import Preview from './preview.js'
+import { formatDate } from './dateutils.js';
+export default {
+ props: ['lastSave'],
+ emits: ['note-open', 'note-new', 'finder-open'],
+ components: { Pane, Icon, Preview },
+ data() {
+ return {
+ query: '',
+ notes: [],
+ notesLength: 0,
+ newLabel: '',
+ previewFilename: '',
+ }
+ },
+ methods: {
+ fetchNotes() {
+ fetch("/api/notes")
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
+ .then(response => {
+ const notes = Object.values(response);
+ this.notesLength = notes.length;
+ this.notes = notes.map(note => {
+ const linkedNotes = [...new Map(
+ [...(note.IncomingLinks || []), ...(note.OutgoingLinks || [])].map(link => [link.Filename, { Filename: link.Filename, Title: link.Title }])
+ ).values()].sort((a, b) => a.Title.localeCompare(b.Title));
+ const linkedLabels = linkedNotes.filter(link => link.Title && !link.Title.includes(' ')).map(link => link.Title);
+ const mtime = new Date(note.Mtime);
+ const ctime = new Date(note.Ctime);
+ return {
+ Filename: note.Filename,
+ Title: note.Title,
+ Mtime: mtime,
+ Ctime: ctime,
+ MtimeRelative: this.formatRelativeDate(mtime),
+ CtimeRelative: this.formatRelativeDate(ctime),
+ IsLabel: note.IsLabel,
+ LinkedLabels: linkedLabels,
+ LinkedNotes: linkedNotes,
+ LinkedNotesCount: linkedNotes.length,
+ SearchStr: (note.Title + ' ' + linkedLabels.join(' ')).toLowerCase(),
+ };
+ })
+ })
+ .catch(e => {
+ console.error(e);
+ });
+ },
+ formatRelativeDate(date) {
+ const now = new Date();
+ const nowTime = now.getTime();
+ const dateTime = date.getTime();
+ const diff = nowTime - dateTime;
+
+ const minutes = Math.floor(diff / 60000); // 60 * 1000
+ const hours = Math.floor(diff / 3600000); // 60 * 60 * 1000
+
+ if (minutes < 0) {
+ // future date
+ } else if (minutes < 1) {
+ return 'Just now';
+ } else if (minutes < 60) {
+ return `${minutes}m ago`;
+ } else if (hours < 24) {
+ return `${hours}h ago`;
+ } else if (hours < 48) {
+ return `Yesterday`;
+ }
+
+ const format = now.getFullYear() === date.getFullYear() ? '%b %d' : '%b %d, %Y';
+ return formatDate(date, format);
+ },
+ createNewLabelNote() {
+ if (this.newLabelStatus.isValid) {
+ const content = `# ${this.newLabel}\n`;
+ this.$emit('note-new', null, content);
+ this.$refs.newLabelInput.blur();
+ this.newLabel='';
+ }
+ },
+ },
+ computed: {
+ newLabelStatus() {
+ if (!this.newLabel) return { isValid: false, error: '' };
+ if (this.newLabel.includes(' ')) return { isValid: false, error: 'not 1-word' };
+ if (this.sortedLabelNotes.some(label => label.Title.toLowerCase() === this.newLabel.toLowerCase())) return { isValid: false, error: 'exists' };
+ return { isValid: true, error: '' };
+ },
+ sortedNotes() {
+ switch(this.$notesiumState.sidePanelSortNotes) {
+ case 'title': return this.notes.sort((a, b) => a.Title.localeCompare(b.Title));
+ case 'links': return this.notes.sort((a, b) => b.LinkedNotesCount - a.LinkedNotesCount);
+ case 'mtime': return this.notes.sort((a, b) => b.Mtime - a.Mtime);
+ case 'ctime': return this.notes.sort((a, b) => b.Ctime - a.Ctime);
+ }
+ },
+ sortedLabelNotes() {
+ switch(this.$notesiumState.sidePanelSortLabels) {
+ case 'title': return this.notes.filter(note => note.IsLabel).sort((a, b) => a.Title.localeCompare(b.Title));
+ case 'links': return this.notes.filter(note => note.IsLabel).sort((a, b) => b.LinkedNotesCount - a.LinkedNotesCount);
+ }
+ },
+ filteredNotes() {
+ const maxNotes = 300;
+ const { query, sortedNotes } = this;
+ if (!query) return sortedNotes.slice(0, maxNotes);
+ const queryWords = query.toLowerCase().split(' ');
+ const labelQuery = queryWords.find(word => word.startsWith('label:'));
+ if (labelQuery) {
+ const label = labelQuery.slice(6);
+ if (!label) return sortedNotes.filter(note => note.IsLabel).slice(0, maxNotes);
+ const notesSubset = sortedNotes.filter(note => note.LinkedLabels.some(l => l.toLowerCase() === label) || note.Title.toLowerCase() === label);
+ const remainingQueryWords = queryWords.filter(word => word !== labelQuery);
+ return notesSubset.filter(note => remainingQueryWords.every(queryWord => note.SearchStr.includes(queryWord))).slice(0, maxNotes);
+ }
+ return sortedNotes.filter(note => queryWords.every(queryWord => note.SearchStr.includes(queryWord))).slice(0, maxNotes);
+ },
+ filteredLabelNotes() {
+ const { query, sortedLabelNotes } = this;
+ if (!query) return sortedLabelNotes;
+ const queryWords = query.toLowerCase().split(' ');
+ return sortedLabelNotes.filter(note => queryWords.every(queryWord => note.SearchStr.includes(queryWord)));
+ },
+ },
+ created() {
+ this.fetchNotes();
+ },
+ watch: {
+ 'lastSave': function() { this.fetchNotes(); },
+ },
+ template: t
+}
+
diff --git a/internal/notesium/web/app/state.js b/internal/notesium/web/app/state.js
new file mode 100644
index 0000000..6d74796
--- /dev/null
+++ b/internal/notesium/web/app/state.js
@@ -0,0 +1,29 @@
+const { reactive, watch } = Vue;
+
+const defaultState = {
+ showGraphPanel: false,
+ showLabelsPanel: false,
+ showNotesPanel: false,
+ showNoteSidebar: true,
+ notesPanelDarkMode: false,
+ notesPanelCompact: false,
+ notesPanelCompactLabels: true,
+ sidePanelSortNotes: 'title', // title, links, mtime, ctime
+ sidePanelSortLabels: 'title', // title, links
+ editorFoldGutter: false,
+ editorLineWrapping: false,
+ editorConcealFormatting: true,
+ editorVimMode: false,
+ startOfWeek: 1, // 0 for Sunday, 1 for Monday, ...
+};
+
+const savedState = localStorage.getItem('notesiumState');
+const initialState = savedState ? JSON.parse(savedState) : defaultState;
+const notesiumState = reactive({ ...defaultState, ...initialState });
+
+watch(notesiumState, (newState) => {
+ Object.assign(newState, { ...defaultState, ...newState });
+ localStorage.setItem('notesiumState', JSON.stringify(newState));
+}, { deep: true });
+
+export { notesiumState };
diff --git a/internal/notesium/web/app/tailwind.config.js b/internal/notesium/web/app/tailwind.config.js
new file mode 100644
index 0000000..5cecf06
--- /dev/null
+++ b/internal/notesium/web/app/tailwind.config.js
@@ -0,0 +1,9 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ["*.js", "!vendor.js", "!tailwind.config.js"],
+ darkMode: 'class',
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/internal/notesium/web/app/tailwind.input.css b/internal/notesium/web/app/tailwind.input.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/internal/notesium/web/app/tailwind.input.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/internal/notesium/web/graph/.gitignore b/internal/notesium/web/graph/.gitignore
new file mode 100644
index 0000000..8f37ed2
--- /dev/null
+++ b/internal/notesium/web/graph/.gitignore
@@ -0,0 +1,2 @@
+tailwind.css
+d3.min.js
diff --git a/internal/notesium/web/graph/README.md b/internal/notesium/web/graph/README.md
new file mode 100644
index 0000000..bbb794f
--- /dev/null
+++ b/internal/notesium/web/graph/README.md
@@ -0,0 +1,100 @@
+As of version [0.5.3](https://github.com/alonswartz/notesium/blob/master/CHANGELOG.md#053), the `web/graph` has been rewritten and implemented
+in `web/app`, with all of same features (except cluster settings and
+darkmode), has tighter integration, and additional improvements.
+
+This graph implemention is backwards compatible, and is especially
+useful for Vim users who don't need the additional features, but instead
+just want a way to view the graph via a single `keybind` and have
+optionally configured the `notesium://` [URI protocol](#custom-uri-protocol) handler for the
+edit links available in the note preview side pane.
+
+## Table of contents
+
+- [Features](#features)
+- [Screenshots](#screenshots)
+- [Download](#download)
+- [CLI](#cli)
+- [Vim](#vim)
+
+## Features
+
+- Visual overview of notes structure with a force graph view.
+- Cluster nodes based on links, inferred from titles or creation date.
+- Adjust node size dynamically based on bi-directional link count.
+- Emphasize nodes and their links using search filter or node click.
+- Preview notes in a side panel. Open for editing via `notesium://` link.
+- Tweak forces such as repel force, collide radius, and strength.
+- Drag, pan, or zoom the graph for a better view or focus.
+- Customize label visibility or automatically scale per zoom level.
+
+## Screenshots
+
+*Graph: display all notes and their links in a force graph view*
+
+
+
+*Graph: cluster notes based on their titles instead of links*
+
+
+
+*Graph: filter notes with emphasized matches. preview note content (dark mode)*
+
+
+
+*Graph: zoomed out large note collection (dark mode)*
+
+
+
+## Download
+
+As of version 0.5.3, the `web/graph` is no longer embedded in the
+release binary, so it needs to be downloaded separately and vendor/css
+files *handled* (depending on your preference).
+
+**Offline usage**
+
+Download vendor files and compile CSS (assumes Linux and [tailwindcss standalone-cli](https://tailwindcss.com/blog/standalone-cli)).
+
+```bash
+git clone https://github.com/alonswartz/notesium.git
+cd notesium
+./web/graph/make.sh all
+```
+
+**CDN usage**
+
+```bash
+git clone https://github.com/alonswartz/notesium.git
+cd notesium
+$EDITOR web/graph/index.html
+```
+
+```diff
+ Notesium Graph
+-
+-
+-
+
+```
+
+## CLI
+
+```bash
+notesium --webroot=/path/to/notesium/web/graph --stop-on-idle --open-browser
+```
+
+## Vim
+
+```vim
+command! -bang NotesiumGraph
+ \ let webroot = "/path/to/notesium/web/graph" |
+ \ let options = "--webroot=".webroot." --stop-on-idle --open-browser" |
+ \ execute ":silent !nohup notesium web ".options." > /dev/null 2>&1 &"
+
+nnoremap ng :NotesiumGraph
+```
+
diff --git a/internal/notesium/web/graph/forcegraph.js b/internal/notesium/web/graph/forcegraph.js
new file mode 100644
index 0000000..c99006f
--- /dev/null
+++ b/internal/notesium/web/graph/forcegraph.js
@@ -0,0 +1,221 @@
+function initialize_forcegraph(data, graphdiv) {
+ const links = data.links.map(d => Object.create(d));
+ const nodes = data.nodes.map(d => Object.create(d));
+
+ const svg = d3.select(graphdiv).append("svg")
+ .style("height", "inherit")
+ .style("width", "inherit")
+ .attr("viewBox", [-160, -180, 320, 360]);
+
+ const simulation = d3.forceSimulation(nodes)
+ .force("link", d3.forceLink(links).id(d => d.id))
+ .force("charge", d3.forceManyBody())
+ .force("collide", d3.forceCollide())
+ .force("center", d3.forceCenter())
+ .force("x", d3.forceX())
+ .force("y", d3.forceY());
+
+ const link = svg.append("g")
+ .classed("link", true)
+ .attr("stroke", "currentColor")
+ .selectAll("line")
+ .data(links)
+ .join("line");
+
+ const node = svg.append("g")
+ .selectAll("circle")
+ .data(nodes)
+ .join("circle")
+ .attr("r", 2)
+ .attr("nid", d => d.id)
+ .classed("node", true)
+ .classed("node-oneword", d => (d.title.split(" ").length == 1))
+ .classed("node-dangling", d => (d.title === 'dangling link'))
+ .classed("node-ghost", d => (d.type === 'ghost'))
+ .call(drag(simulation));
+
+ const title = node.append("title")
+ .text(d => (d.type === "ghost" ? "ghost: " + d.title : d.id + ": " + d.title));
+
+ const label = svg.append("g")
+ .selectAll("circle")
+ .data(nodes)
+ .enter()
+ .append('a')
+ .append("text")
+ .classed("label", true)
+ .attr("alignment-baseline", "middle")
+ .attr("href", node => (node.type === 'ghost' ? null : node.id))
+ .text(node => (node.title == 'dangling link') ? '' : node.title);
+
+ const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) {
+ svg.selectAll('g').attr('transform', event.transform);
+ if (d3.select("#forcegraph-scale-labels").property("checked")) {
+ scaleLabels(event);
+ }
+ });
+ svg.call(zoom);
+
+ simulation.on("tick", () => {
+ link
+ .attr("x1", d => d.source.x)
+ .attr("y1", d => d.source.y)
+ .attr("x2", d => d.target.x)
+ .attr("y2", d => d.target.y);
+ node
+ .attr("cx", d => d.x)
+ .attr("cy", d => d.y);
+ label
+ .attr('x', d => d.x + 4).attr('y', d => d.y);
+ });
+
+ // emphasize or de-emphasize nodes and their links and labels
+ var emphasizeNodesArr = [];
+ function emphasizeNodes() {
+ if (emphasizeNodesArr.length > 0) {
+ const _linkedIds = data.links.filter(l => emphasizeNodesArr.includes(l.source) || emphasizeNodesArr.includes(l.target));
+ const linkedIds = Array.from(new Set(_linkedIds.flatMap(l => [l.source, l.target])));
+ node.attr("fill-opacity", 0.1);
+ node.filter(n => linkedIds.includes(n.id)).attr("fill-opacity", 0.3)
+ node.filter(n => emphasizeNodesArr.includes(n.id)).attr("fill-opacity", 1)
+ label.attr("fill-opacity", 0.3).attr("font-weight", "normal");
+ label.filter(l => linkedIds.includes(l.id)).attr("fill-opacity", 1)
+ label.filter(l => emphasizeNodesArr.includes(l.id)).attr("fill-opacity", 1).attr("font-weight", "bold")
+ link.attr("stroke", "currentColor").attr("stroke-opacity", 0.3);
+ link.filter(l => emphasizeNodesArr.includes(l.source.id) || emphasizeNodesArr.includes(l.target.id)).attr("stroke-opacity", 1);
+ } else {
+ node.attr("fill-opacity", 1)
+ label.attr("fill-opacity", 1).attr("font-weight", "normal");
+ link.attr("stroke", "currentColor").attr("stroke-opacity", 1);
+ }
+ };
+
+ // emphasize or de-emphasize when nodes are clicked
+ node.on("click", function(event) {
+ const nid = event.target.attributes.nid.value;
+ emphasizeNodesArr.includes(nid) ?
+ emphasizeNodesArr.splice(emphasizeNodesArr.indexOf(nid), 1) :
+ emphasizeNodesArr.push(nid);
+ emphasizeNodes();
+ });
+
+ // filter: filtered list
+ function filteredList(results) {
+ const resultsDom = d3.select("#forcegraph-filter-results");
+ const resultsSorted = results.sort((a, b) => a.title.localeCompare(b.title));
+ const searchWords = d3.select("#forcegraph-filter").node().value.toLowerCase().split(" ").filter(n => n.replace(/\W/g, ''));
+ const searchExp = new RegExp(`(${searchWords.join('|')})`, 'ig');
+ resultsDom.html("");
+ resultsSorted.forEach(n => {
+ var href = n.type === 'ghost' ? null : n.id;
+ var title = n.title.replace(searchExp, '$1 ');
+ resultsDom.append("li").append("a").attr("href", href).html(title)
+ });
+ }
+
+ // filter: search node titles word-wise
+ function searchNodes(searchStr) {
+ const searchWords = searchStr.toLowerCase().split(" ");
+ return data.nodes.filter(n => {
+ return searchWords.every(searchWord =>
+ n.title.toLowerCase().includes(searchWord)
+ );
+ });
+ }
+
+ // filter: list and graph emphasis when node titles match
+ d3.select('#forcegraph-filter').on('input', function() {
+ if (this.value) {
+ const results = searchNodes(this.value);
+ filteredList(results);
+ emphasizeNodesArr = [...results.map(n => n.id)];
+ emphasizeNodes();
+ } else {
+ filteredList([]);
+ emphasizeNodesArr.splice(0);
+ emphasizeNodes();
+ }
+ });
+
+ // dynamic circle radius based on links count
+ d3.select("#forcegraph-dynamic-radius").on("change", function() {
+ if (this.checked) {
+ node.attr("r", (d) => data.links.reduce((i, l) => (l.source === d.id || l.target === d.id) ? i + 0.1 : i, 1));
+ } else {
+ node.attr("r", 2);
+ }
+ });
+
+ // toggle labels
+ d3.select("#forcegraph-labels").on("change", function() {
+ svg.selectAll('.label').classed("hidden", !this.checked);
+ });
+
+ // scale labels
+ d3.select("#forcegraph-scale-labels").on("change", scaleLabels);
+ function scaleLabels(event) {
+ let k = 1;
+ let labelSize = 5;
+ switch (event.type) {
+ case "zoom":
+ k = event.transform.k;
+ break;
+ case "change":
+ if (event.target.checked) k = d3.zoomTransform(svg.node()).k;
+ break;
+ }
+ labelSize = k > 0.9 ? labelSize - k : 0;
+ svg.selectAll('.label').transition().style("font-size", labelSize + "px");
+ }
+
+ // repel force strength
+ d3.select("#forcegraph-force-strength").on("change", function() {
+ d3.select("#forcegraph-force-strength-value").text(this.value);
+ simulation.force("charge", d3.forceManyBody().strength(this.value));
+ simulation.alpha(1).restart();
+ });
+
+ // collide radius
+ d3.select("#forcegraph-collide-radius").on("change", function() {
+ d3.select("#forcegraph-collide-radius-value").text(this.value);
+ simulation.force("collide").radius(this.value);
+ simulation.alpha(1).restart();
+ });
+
+ // collide strength
+ d3.select("#forcegraph-collide-strength").on("change", function() {
+ d3.select("#forcegraph-collide-strength-value").text(this.value);
+ simulation.force("collide").strength(this.value);
+ simulation.alpha(1).restart();
+ });
+
+ // trigger settings consistency upon initialization
+ d3.select('#forcegraph-filter').dispatch("change");
+ d3.select("#forcegraph-dynamic-radius").dispatch("change");
+ d3.select("#forcegraph-labels").dispatch("change");
+ d3.select("#forcegraph-scale-labels").dispatch("change");
+ d3.select("#forcegraph-force-strength").dispatch("change");
+ d3.select("#forcegraph-collide-radius").dispatch("change");
+ d3.select("#forcegraph-collide-strength").dispatch("change");
+
+ function drag(simulation) {
+ function dragstarted(event, d) {
+ if (!event.active) simulation.alphaTarget(0.3).restart();
+ d.fx = d.x;
+ d.fy = d.y;
+ }
+ function dragged(event, d) {
+ d.fx = event.x;
+ d.fy = event.y;
+ }
+ function dragended(event, d) {
+ if (!event.active) simulation.alphaTarget(0);
+ d.fx = null;
+ d.fy = null;
+ }
+ return d3.drag()
+ .on("start", dragstarted)
+ .on("drag", dragged)
+ .on("end", dragended);
+ }
+}
diff --git a/internal/notesium/web/graph/index.html b/internal/notesium/web/graph/index.html
new file mode 100644
index 0000000..49b6618
--- /dev/null
+++ b/internal/notesium/web/graph/index.html
@@ -0,0 +1,393 @@
+
+
+
+
+
+ Notesium Graph
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/notesium/web/graph/make.sh b/internal/notesium/web/graph/make.sh
new file mode 100755
index 0000000..7df3442
--- /dev/null
+++ b/internal/notesium/web/graph/make.sh
@@ -0,0 +1,60 @@
+#!/bin/bash -e
+
+fatal() { echo "Fatal: $*" 1>&2; exit 1; }
+
+usage() {
+cat</dev/null || fatal "curl not found"
+ command -v sha256sum >/dev/null || fatal "sha256sum not found"
+ __vendor "$D3JS_URL" "$D3JS_HASH"
+}
+
+_tailwind() {
+ # tailwindcss v3.1.6
+ OPTS="$@"
+ command -v tailwindcss >/dev/null || fatal "tailwindcss not found"
+ [ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found"
+ [ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found"
+ tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css
+}
+
+main() {
+ cd $(dirname $(realpath $0))
+ case $1 in
+ ""|-h|--help|help) usage;;
+ all) _vendor; _tailwind;;
+ vendor) _vendor;;
+ tailwind) shift; _tailwind $@;;
+ *) fatal "unrecognized command: $1";;
+ esac
+}
+
+main "$@"
diff --git a/internal/notesium/web/graph/tailwind.config.js b/internal/notesium/web/graph/tailwind.config.js
new file mode 100644
index 0000000..aa9b206
--- /dev/null
+++ b/internal/notesium/web/graph/tailwind.config.js
@@ -0,0 +1,9 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ["*.html"],
+ darkMode: 'class',
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/internal/notesium/web/graph/tailwind.input.css b/internal/notesium/web/graph/tailwind.input.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/internal/notesium/web/graph/tailwind.input.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/internal/notesium/web/index.html b/internal/notesium/web/index.html
new file mode 100644
index 0000000..e7694c7
--- /dev/null
+++ b/internal/notesium/web/index.html
@@ -0,0 +1,10 @@
+
+
+
+ Redirecting...
+
+
+
+ If you are not redirected, click here .
+
+