From 094250609c9b6667c017bd14882fd5a5af086185 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 21:52:25 +0200 Subject: [PATCH] Fork Notesium source and restructure into Go package layout Initial fork of github.com/alonswartz/notesium into librenotes: - Source moved to internal/notesium/ (package notesium) - Thin entry point at cmd/librenotes/main.go - Module renamed to git.librete.ch/public/librenotes - main() exposed as notesium.Run() - LICENSE preserved (MIT), NOTICE added with attribution - Web assets and completion.bash co-located with embedding code to satisfy go:embed path constraints Closes #3, #34, #35. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 + LICENSE | 23 + NOTICE | 13 + cmd/librenotes/main.go | 7 + go.mod | 18 + go.sum | 87 +++ internal/notesium/api.go | 373 +++++++++++ internal/notesium/cache.go | 142 ++++ internal/notesium/completion.bash | 49 ++ internal/notesium/filter.go | 83 +++ internal/notesium/filter_test.go | 180 ++++++ internal/notesium/finder.go | 54 ++ internal/notesium/heartbeat.go | 51 ++ internal/notesium/highlight.go | 175 +++++ internal/notesium/notesium.go | 605 ++++++++++++++++++ internal/notesium/options.go | 427 ++++++++++++ internal/notesium/runtime.go | 82 +++ internal/notesium/sort.go | 49 ++ internal/notesium/version.go | 119 ++++ internal/notesium/version_test.go | 75 +++ internal/notesium/web/app/.gitignore | 4 + internal/notesium/web/app/alert.js | 40 ++ internal/notesium/web/app/app.js | 480 ++++++++++++++ internal/notesium/web/app/cm-table.js | 184 ++++++ internal/notesium/web/app/cm-vim.js | 81 +++ internal/notesium/web/app/confirm.js | 54 ++ internal/notesium/web/app/datepicker.js | 145 +++++ internal/notesium/web/app/dateutils.js | 35 + internal/notesium/web/app/empty.js | 60 ++ internal/notesium/web/app/favicon.svg | 1 + internal/notesium/web/app/finder.js | 107 ++++ internal/notesium/web/app/graph-d3.js | 204 ++++++ internal/notesium/web/app/graph-panel.js | 158 +++++ internal/notesium/web/app/graph.js | 164 +++++ internal/notesium/web/app/icon.js | 116 ++++ internal/notesium/web/app/index.html | 124 ++++ internal/notesium/web/app/link-tree.js | 58 ++ internal/notesium/web/app/make.sh | 80 +++ internal/notesium/web/app/nav-tabs.js | 122 ++++ internal/notesium/web/app/note-sidebar.js | 137 ++++ internal/notesium/web/app/note-statusbar.js | 64 ++ internal/notesium/web/app/note.js | 333 ++++++++++ internal/notesium/web/app/pane.js | 71 ++ internal/notesium/web/app/periodic.js | 62 ++ internal/notesium/web/app/preview.js | 82 +++ internal/notesium/web/app/ribbon.js | 84 +++ internal/notesium/web/app/settings-about.js | 157 +++++ internal/notesium/web/app/settings-editor.js | 160 +++++ .../notesium/web/app/settings-keybinds.js | 65 ++ internal/notesium/web/app/settings-stats.js | 61 ++ internal/notesium/web/app/settings.js | 54 ++ internal/notesium/web/app/sidepanel.js | 387 +++++++++++ internal/notesium/web/app/state.js | 29 + internal/notesium/web/app/tailwind.config.js | 9 + internal/notesium/web/app/tailwind.input.css | 3 + internal/notesium/web/graph/.gitignore | 2 + internal/notesium/web/graph/README.md | 100 +++ internal/notesium/web/graph/forcegraph.js | 221 +++++++ internal/notesium/web/graph/index.html | 393 ++++++++++++ internal/notesium/web/graph/make.sh | 60 ++ .../notesium/web/graph/tailwind.config.js | 9 + .../notesium/web/graph/tailwind.input.css | 3 + internal/notesium/web/index.html | 10 + 63 files changed, 7391 insertions(+) create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 cmd/librenotes/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/notesium/api.go create mode 100644 internal/notesium/cache.go create mode 100644 internal/notesium/completion.bash create mode 100644 internal/notesium/filter.go create mode 100644 internal/notesium/filter_test.go create mode 100644 internal/notesium/finder.go create mode 100644 internal/notesium/heartbeat.go create mode 100644 internal/notesium/highlight.go create mode 100644 internal/notesium/notesium.go create mode 100644 internal/notesium/options.go create mode 100644 internal/notesium/runtime.go create mode 100644 internal/notesium/sort.go create mode 100644 internal/notesium/version.go create mode 100644 internal/notesium/version_test.go create mode 100644 internal/notesium/web/app/.gitignore create mode 100644 internal/notesium/web/app/alert.js create mode 100644 internal/notesium/web/app/app.js create mode 100644 internal/notesium/web/app/cm-table.js create mode 100644 internal/notesium/web/app/cm-vim.js create mode 100644 internal/notesium/web/app/confirm.js create mode 100644 internal/notesium/web/app/datepicker.js create mode 100644 internal/notesium/web/app/dateutils.js create mode 100644 internal/notesium/web/app/empty.js create mode 100644 internal/notesium/web/app/favicon.svg create mode 100644 internal/notesium/web/app/finder.js create mode 100644 internal/notesium/web/app/graph-d3.js create mode 100644 internal/notesium/web/app/graph-panel.js create mode 100644 internal/notesium/web/app/graph.js create mode 100644 internal/notesium/web/app/icon.js create mode 100644 internal/notesium/web/app/index.html create mode 100644 internal/notesium/web/app/link-tree.js create mode 100755 internal/notesium/web/app/make.sh create mode 100644 internal/notesium/web/app/nav-tabs.js create mode 100644 internal/notesium/web/app/note-sidebar.js create mode 100644 internal/notesium/web/app/note-statusbar.js create mode 100644 internal/notesium/web/app/note.js create mode 100644 internal/notesium/web/app/pane.js create mode 100644 internal/notesium/web/app/periodic.js create mode 100644 internal/notesium/web/app/preview.js create mode 100644 internal/notesium/web/app/ribbon.js create mode 100644 internal/notesium/web/app/settings-about.js create mode 100644 internal/notesium/web/app/settings-editor.js create mode 100644 internal/notesium/web/app/settings-keybinds.js create mode 100644 internal/notesium/web/app/settings-stats.js create mode 100644 internal/notesium/web/app/settings.js create mode 100644 internal/notesium/web/app/sidepanel.js create mode 100644 internal/notesium/web/app/state.js create mode 100644 internal/notesium/web/app/tailwind.config.js create mode 100644 internal/notesium/web/app/tailwind.input.css create mode 100644 internal/notesium/web/graph/.gitignore create mode 100644 internal/notesium/web/graph/README.md create mode 100644 internal/notesium/web/graph/forcegraph.js create mode 100644 internal/notesium/web/graph/index.html create mode 100755 internal/notesium/web/graph/make.sh create mode 100644 internal/notesium/web/graph/tailwind.config.js create mode 100644 internal/notesium/web/graph/tailwind.input.css create mode 100644 internal/notesium/web/index.html 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 = ` +
+ +
+

+
+ + + +
+
+ +
+
+
+ +
+
+ + + + + +
+
+ +
+` + +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 = ` +
+

notesium

+
+ +
+
+` + +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 = ` + +` + +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 = ` +
+
+
+ + +
+ +
+ +
+ +
+
+` + +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 = ` + + + +` + +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 = ` +
+
+ + +
+ +
+
+ +
+
+ + + + +
+
+ +
+` + +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 = ` +
+
+ + +
+ +
+ + + + +
+ + + + +
+` + +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 = ` + +
+
+ +
+
+ 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 = ` +
+
+ +
+
+
+
Software update
+
+
Check for updates
+
+
+
Runtime version
+
+
+
+
Latest release
+
+
+
+
Last check
+
+
+
+
+ +
+ You are using the latest version +
+
+ You are using a newer version than the latest release +
+
+ + + +
+
+
+
Runtime
+
+ + +
+ +
state
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+ +
+
+` + +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 = ` +
+
+ +
+
+
+
Counts
+
+
+
+
+
+
+
+
+
+ +
+
+` + +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 = ` + +` + +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 = ` + +
+
    +
  • + + + +
  • + +
  • + +
    + + + +
    +
  • +
+
+
+ + + + +
+
+
+ +
+
+
+
+ +
+ + +
+
+ +
+ +
+ + + + +
+ +
+ + + + +
+ +
+
+ +
+ +
    +
  • +
    + +
    +
    + +
    + + +
    + +
    +
    +
    +
    + +
    +
    +
    +
  • +
  • +
    + +
  • +
+ +
    +
  • +
    +
    +
    + + +
    + +
    +
    +
    + +
  • +
+ +
+
+ {{ filteredNotes.length }}/{{ notesLength }} matches for "" +
+
Full text search →
+
+ +
+
+` + +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* +![image: force graph cluster links](https://www.notesium.com/images/screenshot-1688650369.png) +
+ +*Graph: cluster notes based on their titles instead of links* +![image: force graph cluster titles](https://www.notesium.com/images/screenshot-1687865971.png) +
+ +*Graph: filter notes with emphasized matches. preview note content (dark mode)* +![image: force graph note preview](https://www.notesium.com/images/screenshot-1690971723.png) +
+ +*Graph: zoomed out large note collection (dark mode)* +![image: force graph zoom](https://www.notesium.com/images/screenshot-1682941869.png) +
+ +## 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 + + + + + + + +
+ + + +
    + +
    +
    + +
    + +
    +
    + +
    +

    display

    + + + +
    + + +
    +

    cluster

    + + + +
    + + +
    +

    forces

    + + + +
    + +
    + +
    +
    + + + + 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.

    + +