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) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -9,3 +9,9 @@
|
||||
!.wave/prompts/
|
||||
wave.yaml
|
||||
.env
|
||||
|
||||
# Build artifacts
|
||||
/librenotes
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
23
LICENSE
Normal file
23
LICENSE
Normal file
@@ -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.
|
||||
|
||||
13
NOTICE
Normal file
13
NOTICE
Normal file
@@ -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.
|
||||
7
cmd/librenotes/main.go
Normal file
7
cmd/librenotes/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "git.librete.ch/public/librenotes/internal/notesium"
|
||||
|
||||
func main() {
|
||||
notesium.Run()
|
||||
}
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -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
|
||||
)
|
||||
87
go.sum
Normal file
87
go.sum
Normal file
@@ -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=
|
||||
373
internal/notesium/api.go
Normal file
373
internal/notesium/api.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
142
internal/notesium/cache.go
Normal file
142
internal/notesium/cache.go
Normal file
@@ -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
|
||||
}
|
||||
49
internal/notesium/completion.bash
Normal file
49
internal/notesium/completion.bash
Normal file
@@ -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
|
||||
83
internal/notesium/filter.go
Normal file
83
internal/notesium/filter.go
Normal file
@@ -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
|
||||
}
|
||||
180
internal/notesium/filter_test.go
Normal file
180
internal/notesium/filter_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
internal/notesium/finder.go
Normal file
54
internal/notesium/finder.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
51
internal/notesium/heartbeat.go
Normal file
51
internal/notesium/heartbeat.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
175
internal/notesium/highlight.go
Normal file
175
internal/notesium/highlight.go
Normal file
@@ -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
|
||||
}
|
||||
605
internal/notesium/notesium.go
Normal file
605
internal/notesium/notesium.go
Normal file
@@ -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
|
||||
}
|
||||
427
internal/notesium/options.go
Normal file
427
internal/notesium/options.go
Normal file
@@ -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",
|
||||
}
|
||||
}
|
||||
82
internal/notesium/runtime.go
Normal file
82
internal/notesium/runtime.go
Normal file
@@ -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])
|
||||
}
|
||||
49
internal/notesium/sort.go
Normal file
49
internal/notesium/sort.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
119
internal/notesium/version.go
Normal file
119
internal/notesium/version.go
Normal file
@@ -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
|
||||
}
|
||||
75
internal/notesium/version_test.go
Normal file
75
internal/notesium/version_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4
internal/notesium/web/app/.gitignore
vendored
Normal file
4
internal/notesium/web/app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.vendor/
|
||||
vendor.js
|
||||
vendor.css
|
||||
tailwind.css
|
||||
40
internal/notesium/web/app/alert.js
Normal file
40
internal/notesium/web/app/alert.js
Normal file
@@ -0,0 +1,40 @@
|
||||
var t = `
|
||||
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div v-if="alert.type == 'error'" class="text-red-400"><Icon name="outline-exclamation-circle" size="h-6 w-6" /></div>
|
||||
<div v-if="alert.type == 'success'" class="text-green-400"><Icon name="outline-check-circle" size="h-6 w-6" /></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900" v-text="alert.title"></p>
|
||||
<p v-show="alert.body" class="mt-1 text-sm text-gray-500" v-text="alert.body"></p>
|
||||
</div>
|
||||
<div v-show="alert.sticky" class="ml-4 flex flex-shrink-0">
|
||||
<button @click="dismiss()" type="button" class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Close</span>
|
||||
<Icon name="mini-x-mark" size="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
480
internal/notesium/web/app/app.js
Normal file
480
internal/notesium/web/app/app.js
Normal file
@@ -0,0 +1,480 @@
|
||||
var t = `
|
||||
<div class="relative flex max-h-screen h-screen overflow-hidden">
|
||||
|
||||
<Ribbon :versionCheck=versionCheck :showPeriodic=showPeriodic
|
||||
@note-new="newNote" @finder-open="openFinder" @graph-open="showGraph=true" @settings-open="showSettings=true" @periodic-open="showPeriodic=true" />
|
||||
|
||||
<SidePanel v-if="$notesiumState.showLabelsPanel || $notesiumState.showNotesPanel"
|
||||
:lastSave="lastSave" @note-open="openNote" @note-new="newNote" @finder-open="openFinder" />
|
||||
|
||||
<GraphPanel v-if="$notesiumState.showGraphPanel" :lastSave=graphPanelWatcher :activeTabId=activeTabId @note-open="openNote" />
|
||||
|
||||
<div class="flex flex-col h-full w-full overflow-x-auto">
|
||||
<nav class="flex bg-gray-200 text-gray-800">
|
||||
<NavTabs :tabs=tabs :activeTabId=activeTabId :previousTabId=previousTabId :notes=notes
|
||||
@tab-activate="activateTab" @tab-move="moveTab" @tab-close="closeTab" @note-close="closeNote" />
|
||||
</nav>
|
||||
<main class="h-full overflow-hidden bg-gray-50">
|
||||
<Empty v-if="tabs.length == 0" @note-new="newNote" @note-daily="dailyNote" @finder-open="openFinder" @graph-open="showGraph=true" />
|
||||
<Note v-show="note.Filename == activeTabId" :note=note v-for="note in notes" :key="note.Filename" :activeTabId=activeTabId
|
||||
@note-open="openNote" @note-close="closeNote" @note-save="saveNote" @note-delete="deleteNote" @finder-open="openFinder" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Periodic v-if="showPeriodic" @note-daily="dailyNote" @note-weekly="weeklyNote" @periodic-close="showPeriodic=false" />
|
||||
<Graph v-if="showGraph" @graph-close="showGraph=false" @note-open="openNote" />
|
||||
<Settings v-if="showSettings" :versionCheck=versionCheck @settings-close="showSettings=false" @version-check="checkVersion" @finder-open="openFinder" />
|
||||
<Finder v-if="showFinder" :uri=finderUri :initialQuery=finderQuery @finder-selection="handleFinderSelection" />
|
||||
<Confirm ref="confirmDialog" />
|
||||
<div v-show="keySequence.length" v-text="keySequence.join(' ')" class="absolute bottom-6 right-4"></div>
|
||||
|
||||
<div aria-live="assertive" class="pointer-events-none fixed inset-0 flex items-end sm:items-start p-2 z-50">
|
||||
<div class="flex w-full flex-col items-center space-y-2 sm:items-end">
|
||||
<Alert :alert=alert v-for="alert in alerts" :key="alert.id" @alert-dismiss="dismissAlert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
184
internal/notesium/web/app/cm-table.js
Normal file
184
internal/notesium/web/app/cm-table.js
Normal file
@@ -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;
|
||||
}
|
||||
81
internal/notesium/web/app/cm-vim.js
Normal file
81
internal/notesium/web/app/cm-vim.js
Normal file
@@ -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('<C')
|
||||
cm.display.input.blur();
|
||||
document.body.focus();
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: key, code: code, ctrlKey: ctrlKey, bubbles: true, cancelable: true, composed: true }));
|
||||
});
|
||||
CodeMirror.Vim.map('<Space>', ':BodyKeyEvent <Space> Space', 'normal');
|
||||
CodeMirror.Vim.map('<C-h>', ':BodyKeyEvent <C-h> KeyH', 'normal');
|
||||
CodeMirror.Vim.map('<C-l>', ':BodyKeyEvent <C-l> KeyL', 'normal');
|
||||
CodeMirror.Vim.map('<C-6>', ':BodyKeyEvent <C-o> Digit6', 'normal');
|
||||
CodeMirror.Vim.map('<C-h>', ':BodyKeyEvent <C-h> KeyH', 'insert');
|
||||
CodeMirror.Vim.map('<C-l>', ':BodyKeyEvent <C-l> KeyL', 'insert');
|
||||
CodeMirror.Vim.map('<C-6>', ':BodyKeyEvent <C-6> 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;
|
||||
});
|
||||
}
|
||||
54
internal/notesium/web/app/confirm.js
Normal file
54
internal/notesium/web/app/confirm.js
Normal file
@@ -0,0 +1,54 @@
|
||||
var t = `
|
||||
<div v-if="visible" @keyup.esc="close(false)" class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20" role="dialog" aria-modal="true" >
|
||||
<div @click="close(false)" class="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" aria-hidden="true"></div>
|
||||
<div class="max-w-xl overflow-y-auto mx-auto transform overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5">
|
||||
<input ref="modalAutoFocus" autofocus type="text" class="sr-only" aria-hidden="true" />
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div class="relative w-full transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all">
|
||||
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900" v-text="config.title"></h3>
|
||||
<pre class="mt-3 text-sm text-gray-500 font-sans whitespace-pre-wrap break-words" v-text="config.body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button @click="close(true)" type="button" class="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto" v-text="config.button"></button>
|
||||
<button @click="close(false)" type="button" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
145
internal/notesium/web/app/datepicker.js
Normal file
145
internal/notesium/web/app/datepicker.js
Normal file
@@ -0,0 +1,145 @@
|
||||
var t = `
|
||||
<div>
|
||||
|
||||
<div class="flex items-center pl-1 pr-2">
|
||||
<h2 class="flex-auto text-sm text-gray-900" v-text="formattedMonthYear"></h2>
|
||||
<div class="flex space-x-4 -mr-2">
|
||||
<button @click="changeMonth(-1)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Previous month</span>
|
||||
<Icon name="chevron-right" size="h-5 w-5" class="rotate-180"/>
|
||||
</button>
|
||||
<button @click="setSelectedDate(this.today)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="text-xs mt-1">Today</span>
|
||||
</button>
|
||||
<button @click="changeMonth(1)" type="button" class="flex flex-none items-center justify-center text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Next month</span>
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-7 gap-2 text-center text-gray-500" style="font-size: 0.65rem;">
|
||||
<div v-for="(day, index) in sortedDaysOfWeek" :key="day" v-text="day" class="hover:cursor-pointer hover:underline"
|
||||
title="set as start of week" @click="$notesiumState.startOfWeek = ($notesiumState.startOfWeek + index) % 7"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-7 gap-2 text-center pb-4 select-none items-center justify-items-center justify-center" style="font-size: 0.65rem;">
|
||||
<div v-for="day in displayedMonthDates" :key="day.date"
|
||||
@click="setSelectedDate(day.date)"
|
||||
@dblclick="$emit('date-dblclick', day.date)"
|
||||
:class="selectedDate === day.date ? (day.isToday ? 'bg-indigo-500' : 'bg-gray-500') : 'hover:bg-gray-200'"
|
||||
class="flex flex-col h-6 w-6 hover:cursor-pointer rounded-full">
|
||||
<span v-text="day.day" class="mt-1" :class="[
|
||||
selectedDate === day.date ? 'text-white' : day.isToday ? 'text-indigo-500' : day.isCurrentMonth ? 'text-gray-900' : 'text-gray-400',
|
||||
(selectedDate === day.date || day.isToday) ? 'font-semibold' : '']"></span>
|
||||
<span class="-mt-1.5">
|
||||
<span v-text="getDotForType(day.date, 'weekly')" :class="selectedDate == day.date ? 'text-white' : 'text-emerald-500'"></span>
|
||||
<span v-text="getDotForType(day.date, 'daily')" :class="selectedDate == day.date ? 'text-white' : 'text-indigo-300'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
35
internal/notesium/web/app/dateutils.js
Normal file
35
internal/notesium/web/app/dateutils.js
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
60
internal/notesium/web/app/empty.js
Normal file
60
internal/notesium/web/app/empty.js
Normal file
@@ -0,0 +1,60 @@
|
||||
var t = `
|
||||
<div class="mx-auto w-96">
|
||||
<h3 class="mt-40 font-medium text-gray-400 text-xl mx-auto text-center">notesium</h3>
|
||||
<div class="mt-10">
|
||||
<template v-for="entry in entries">
|
||||
<div class="flex items-center justify-between p-4 text-sm font-medium cursor-pointer hover:bg-gray-100 rounded-xl"
|
||||
@click="$emit(entry.emit[0], entry.emit[1])">
|
||||
<div class="flex items-center space-x-4 text-gray-500">
|
||||
<Icon :name="entry.icon" size="h-4 w-4" />
|
||||
<span v-text="entry.title"></span>
|
||||
</div>
|
||||
<span class="whitespace-nowrap text-gray-900 font-mono mt-1" v-text="entry.keybind"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
1
internal/notesium/web/app/favicon.svg
Normal file
1
internal/notesium/web/app/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="-25 -25 562 562"><path fill-rule="nonzero" d="M256.002 0c70.69 0 134.688 28.658 181.017 74.981C483.343 121.311 512 185.309 512 255.998c0 70.69-28.657 134.694-74.981 181.018C390.693 483.342 326.692 512 256.002 512s-134.694-28.655-181.017-74.984C28.655 390.692 0 326.688 0 255.998c0-70.689 28.658-134.69 74.985-181.017C121.308 28.658 185.312 0 256.002 0zm26.853 349.763l-45.906-66.608c-1.601-2.202-2.601-7.001-3.001-14.401h-1.201v81.009H172.74V162.24h56.406l45.906 66.608c1.601 2.202 2.601 7.001 3 14.401h1.202V162.24h60.006v187.523h-56.405zm126.972-247.589C370.462 62.812 316.074 38.46 256.002 38.46c-60.073 0-114.464 24.352-153.825 63.714-39.365 39.364-63.716 93.752-63.716 153.824 0 60.073 24.351 114.464 63.716 153.825 39.361 39.365 93.752 63.717 153.825 63.717 60.072 0 114.46-24.352 153.825-63.717 39.362-39.361 63.713-93.752 63.713-153.825 0-60.072-24.351-114.46-63.713-153.824z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
107
internal/notesium/web/app/finder.js
Normal file
107
internal/notesium/web/app/finder.js
Normal file
@@ -0,0 +1,107 @@
|
||||
var t = `
|
||||
<div @keyup.esc="handleSelection(null)" class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20" role="dialog" aria-modal="true" >
|
||||
<div @click="handleSelection(null)" class="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" aria-hidden="true"></div>
|
||||
<div :class="(small && !preview) ? 'max-w-2xl h-96' : 'w-full h-full'"
|
||||
class="mx-auto flex flex-col transform overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black ring-opacity-5">
|
||||
<div class="relative group flex items-center justify-items-center justify-between space-x-2 border-b border-gray-200">
|
||||
<input ref="queryInput" v-model="query" autofocus placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@blur="$refs.queryInput && $refs.queryInput.focus()"
|
||||
@keydown.down.prevent="selectDown()"
|
||||
@keydown.up.prevent="selectUp()"
|
||||
@keydown.ctrl.j.prevent="selectDown()"
|
||||
@keydown.ctrl.k.prevent="selectUp()"
|
||||
@keydown.ctrl.p.prevent="preview=!preview"
|
||||
@keyup.enter.prevent="(filteredItems.length === 0) ? undefined : handleSelection(selected)"
|
||||
@keydown.tab.prevent
|
||||
class="h-12 bg-gray-100 w-full border-0 m-2 rounded-lg px-4 text-gray-900 placeholder:text-gray-400 ring-0 focus:outline-none text-sm" />
|
||||
<p class="text-sm px-4">{{ filteredItems.length }}/{{ itemsLength }}</p>
|
||||
</div>
|
||||
<div :class="(preview) ? 'grid-cols-2' : 'grid-cols-1'" class="h-full overflow-y-auto grid divide-x divide-gray-200">
|
||||
<ul role="listbox" class="max-h-full overflow-y-auto py-1 text-sm font-mono text-gray-800 focus:outline-none">
|
||||
<template v-for="item, index in filteredItems">
|
||||
<li :id="'item'+index" @click="selected = index" @dblclick="handleSelection(index)" role="option"
|
||||
:class="{'!bg-blue-500 text-white': index === selected }"
|
||||
class="hover:bg-indigo-600/10 cursor-pointer select-none px-4 py-1 whitespace-nowrap truncate">
|
||||
<b v-if="item.Colored" v-text="item.Colored"></b> {{ item.Content }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="max-h-full overflow-y-auto p-1 text-sm font-mono text-gray-800">
|
||||
<template v-if="filteredItems.length > 0 && preview">
|
||||
<Preview :filename=filteredItems[selected].Filename :lineNumber=filteredItems[selected].Linenum />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
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
|
||||
}
|
||||
204
internal/notesium/web/app/graph-d3.js
Normal file
204
internal/notesium/web/app/graph-d3.js
Normal file
@@ -0,0 +1,204 @@
|
||||
var t = `
|
||||
<div class="forcegraph h-full w-full overflow-hidden text-gray-200" ref="forcegraph"></div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
158
internal/notesium/web/app/graph-panel.js
Normal file
158
internal/notesium/web/app/graph-panel.js
Normal file
@@ -0,0 +1,158 @@
|
||||
var t = `
|
||||
<Pane name="graphPanel" :defaultWidth="540" :minWidth="100">
|
||||
<div class="h-full w-full border-r border-gray-300 bg-gray-50">
|
||||
|
||||
<div class="flex flex-nowrap max-w-full w-full h-9 overflow-x-hidden items-center content-center px-2 mr-6 bg-gray-200">
|
||||
<div class="relative h-full text-gray-50">
|
||||
<svg class="absolute right-0 bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z"></path></svg>
|
||||
</div>
|
||||
<div class="flex rounded-t-lg justify-between basis-52 truncate text-xs h-full items-center pl-3 pr-2 bg-gray-50 text-gray-800">
|
||||
<span class="truncate pt-px">graph view</span>
|
||||
<span title="close" @click="$notesiumState.showGraphPanel=false" class="hover:bg-gray-300 hover:rounded-full cursor-pointer">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative h-full text-gray-50">
|
||||
<svg class="absolute bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!showSettings" class="absolute top-9 right-0 p-3">
|
||||
<div class="border border-gray-200 rounded-md backdrop-blur-md">
|
||||
<span title="settings" @click="showSettings=true"
|
||||
class="h-8 px-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showSettings" class="absolute top-9 right-0 p-3 w-60">
|
||||
<div class="flex flex-1 flex-col border border-gray-200 rounded-md backdrop-blur-md space-y-1">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<input ref="queryInput" v-model="query" placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="query=''"
|
||||
@keyup.enter.prevent
|
||||
@keydown.tab.prevent
|
||||
class="h-8 w-full p-2 focus:outline-none backdrop-blur-md bg-inherit text-sm text-gray-700 placeholder:text-gray-400" />
|
||||
<span title="settings" @click="showSettings=false"
|
||||
class="h-8 pr-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<details class="w-full cursor-pointer [&_svg]:open:rotate-90">
|
||||
<summary class="flex py-1 px-2 items-center justify-items-center justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-sm font-medium leading-6 text-gray-700">display</span>
|
||||
<span class="text-gray-400 -mr-px"><Icon name="chevron-right" size="h-5 w-5" /></span>
|
||||
</summary>
|
||||
<ul class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in display" :key="key" @click="option.value=!option.value"
|
||||
class="flex items-center justify-items-center justify-between block p-2 pr-0">
|
||||
<label v-text="option.title"></label>
|
||||
<input v-model="option.value" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500" />
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<details class="w-full cursor-pointer [&_svg]:open:rotate-90">
|
||||
<summary class="flex py-1 px-2 items-center justify-items-center justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-sm font-medium leading-6 text-gray-700">forces</span>
|
||||
<span class="text-gray-400 -mr-px"><Icon name="chevron-right" size="h-5 w-5" /></span>
|
||||
</summary>
|
||||
<ul class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in forces" :key="key"
|
||||
class="items-center justify-items-center justify-between block p-2 pr-0">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span v-text="option.title"></span>
|
||||
<span v-text="option.value"></span>
|
||||
</div>
|
||||
<input class="w-full cursor-pointer" type="range" v-model="option.value" :min="option.min" :max="option.max" :step="option.step" />
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphD3 ref="forcegraph" v-if="graphData"
|
||||
:graphData=graphData
|
||||
:display=display
|
||||
:forces=forces
|
||||
:emphasizeNodeIds=emphasizeNodeIds
|
||||
:initialTransform=initialTransform
|
||||
@title-click="$emit('note-open', $event)"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
164
internal/notesium/web/app/graph.js
Normal file
164
internal/notesium/web/app/graph.js
Normal file
@@ -0,0 +1,164 @@
|
||||
var t = `
|
||||
<div class="relative inset-0 z-50" aria-labelledby="graph" role="dialog" aria-modal="true">
|
||||
<div @click="$emit('graph-close');" class="fixed inset-0" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex">
|
||||
<div class="w-screen pointer-events-auto">
|
||||
<div class="flex flex-col h-full bg-white pb-6 shadow-xl">
|
||||
|
||||
<div class="absolute top-0 right-0 flex items-center justify-items-center space-x-3 p-2">
|
||||
<div title="close" @click="$emit('graph-close')"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 left-0 w-60 p-3">
|
||||
<div class="flex flex-1 flex-col overflow-y-auto border border-gray-200 rounded-md backdrop-blur-md bg-gray-50/10 space-y-1">
|
||||
<div class="flex space-x-1" :class="{ 'border-b border-gray-200': showSettings }">
|
||||
<span title="settings" @click="showSettings=!showSettings"
|
||||
class="h-10 px-2 cursor-pointer inline-flex items-center justify-items-center text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</span>
|
||||
<input ref="queryInput" v-model="query" autofocus placeholder="filter notes..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="selectedNodeId ? (query = '', selectedNodeId = '') : $emit('graph-close');"
|
||||
@keyup.enter.prevent="(emphasizeNodeIds.length === 1) ? selectedNodeId = emphasizeNodeIds[0] : undefined"
|
||||
@keydown.tab.prevent
|
||||
@blur="$refs.queryInput && $refs.queryInput.focus()"
|
||||
class="h-10 w-full py-2 pr-2 focus:outline-none backdrop-blur-md bg-inherit text-sm text-gray-700 placeholder:text-gray-400" />
|
||||
</div>
|
||||
<div v-show="showSettings">
|
||||
<div @click="showSettingsDisplay=!showSettingsDisplay"
|
||||
class="hover:bg-gray-50 cursor-pointer flex items-center w-full text-left p-2 space-x-3">
|
||||
<span class="text-gray-400 shrink-0" :class="{ 'rotate-90' : showSettingsDisplay }">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm leading-6 text-gray-700">display</span>
|
||||
</div>
|
||||
<ul v-show="showSettingsDisplay" class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in display" :key="key" @click="option.value=!option.value"
|
||||
class="flex items-center justify-items-center justify-between hover:bg-gray-50 block rounded-md py-2 pr-2 pl-8">
|
||||
<label v-text="option.title"></label>
|
||||
<input v-model="option.value" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500" />
|
||||
</li>
|
||||
</ul>
|
||||
<div @click="showSettingsForces=!showSettingsForces"
|
||||
class="hover:bg-gray-50 cursor-pointer flex items-center w-full text-left p-2 gap-x-3">
|
||||
<span class="text-gray-400 shrink-0" :class="{ 'rotate-90' : showSettingsForces }">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm leading-6 text-gray-700">forces</span>
|
||||
</div>
|
||||
<ul v-show="showSettingsForces" class="mt-1 ml-px px-2 text-xs leading-6 text-gray-700">
|
||||
<li v-for="(option, key) in forces" :key="key"
|
||||
class="items-center justify-items-center justify-between block rounded-md py-2 pr-2 pl-8">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span v-text="option.title"></span>
|
||||
<span v-text="option.value"></span>
|
||||
</div>
|
||||
<input class="w-full" type="range" v-model="option.value" :min="option.min" :max="option.max" :step="option.step" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNodeId" class="absolute top-0 right-0 w-[38rem] flex flex-col h-full bg-white shadow-xl">
|
||||
<div class="flex items-center justify-end mx-2 pt-2 space-x-2">
|
||||
<span title="open for editing" @click="$emit('note-open', selectedNodeId); $emit('graph-close')"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="pencil-solid" size="h-5 w-5 p-px" />
|
||||
</span>
|
||||
<span title="close preview" @click="selectedNodeId=''"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-full pl-4 pb-4 mr-1 overflow-y-auto">
|
||||
<Preview clickableLinks=true appendIncomingLinks=true :filename=selectedNodeId @note-open="selectedNodeId = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphD3 v-if="graphData"
|
||||
:graphData=graphData
|
||||
:emphasizeNodeIds=emphasizeNodeIds
|
||||
:display=display
|
||||
:forces=forces
|
||||
@click="(query = '', selectedNodeId = '')"
|
||||
@title-click="(query = '', selectedNodeId = $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
116
internal/notesium/web/app/icon.js
Normal file
116
internal/notesium/web/app/icon.js
Normal file
@@ -0,0 +1,116 @@
|
||||
var t = `
|
||||
<svg v-if="name == 'mini-bars-three-bottom-left'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-arrows-right-left'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M13.2 2.24a.75.75 0 00.04 1.06l2.1 1.95H6.75a.75.75 0 000 1.5h8.59l-2.1 1.95a.75.75 0 101.02 1.1l3.5-3.25a.75.75 0 000-1.1l-3.5-3.25a.75.75 0 00-1.06.04zm-6.4 8a.75.75 0 00-1.06-.04l-3.5 3.25a.75.75 0 000 1.1l3.5 3.25a.75.75 0 101.02-1.1l-2.1-1.95h8.59a.75.75 0 000-1.5H4.66l2.1-1.95a.75.75 0 00.04-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-magnifying-glass'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-x-mark'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-plus'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-check-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-exclamation-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-information-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-code'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-external-link'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-link-slash'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.181 8.68a4.503 4.503 0 0 1 1.903 6.405m-9.768-2.782L3.56 14.06a4.5 4.5 0 0 0 6.364 6.365l3.129-3.129m5.614-5.615 1.757-1.757a4.5 4.5 0 0 0-6.364-6.365l-4.5 4.5c-.258.26-.479.541-.661.84m1.903 6.405a4.495 4.495 0 0 1-1.242-.88 4.483 4.483 0 0 1-1.062-1.683m6.587 2.345 5.907 5.907m-5.907-5.907L8.898 8.898M2.991 2.99 8.898 8.9" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-ellipsis-vertical'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'panel-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" :class="size">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path><rect x="4" y="4" width="16" height="16" rx="2"></rect><line x1="15" y1="4" x2="15" y2="20"></line>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'graph'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" :class="size">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path><circle cx="12" cy="5" r="2"></circle><circle cx="5" cy="19" r="2"></circle><circle cx="19" cy="19" r="2"></circle><path d="M6.5 17.5l5.5 -4.5l5.5 4.5"></path><line x1="12" y1="7" x2="12" y2="13"></line>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-bars-arrow-down'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-tag'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-view-columns'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'mini-check'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-queue-list'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-adjustments-horizontal'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'chevron-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'pencil-solid'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" :class="size">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-calendar'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'outline-trash'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" :class="size">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'spinner-circle'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="animate-spin" :class="size">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name == 'micro-arrow-uturn-right'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" :class="size">
|
||||
<path fill-rule="evenodd" d="M3.5 9.75A2.75 2.75 0 0 1 6.25 7h5.19L9.22 9.22a.75.75 0 1 0 1.06 1.06l3.5-3.5a.75.75 0 0 0 0-1.06l-3.5-3.5a.75.75 0 1 0-1.06 1.06l2.22 2.22H6.25a4.25 4.25 0 0 0 0 8.5h1a.75.75 0 0 0 0-1.5h-1A2.75 2.75 0 0 1 3.5 9.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
export default {
|
||||
props: ['name', 'size'],
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
template: t
|
||||
}
|
||||
124
internal/notesium/web/app/index.html
Normal file
124
internal/notesium/web/app/index.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Notesium Web</title>
|
||||
<script src="vendor.js"></script>
|
||||
<link rel="stylesheet" href="vendor.css" />
|
||||
<link rel="stylesheet" href="tailwind.css" />
|
||||
<link rel="shortcut icon" href="favicon.svg">
|
||||
<style>
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { margin: 6px 0px 6px 0px; }
|
||||
::-webkit-scrollbar-thumb { background-color: #e5e7eb; border-radius: 5px; }
|
||||
::-webkit-scrollbar:horizontal { height: 10px; }
|
||||
.dark-scroll::-webkit-scrollbar-thumb { background-color: #64748b !important; }
|
||||
|
||||
.forcegraph .node { fill: #ff7f0e; }
|
||||
.forcegraph .node-label { fill: #1f77b4; }
|
||||
.forcegraph .link { stroke-width: 0.5; }
|
||||
.forcegraph .title { fill: #475569; font-size: 4px; cursor: pointer; alignment-baseline: middle; }
|
||||
.forcegraph .title:hover { text-decoration: underline; }
|
||||
|
||||
.cm-s-notesium-light.CodeMirror { color: #1f2937; background: transparent; min-height: 100%; }
|
||||
.cm-s-notesium-light pre.CodeMirror-placeholder { color: #999; }
|
||||
.cm-s-notesium-light span.cm-comment { color: #8f5536; }
|
||||
.cm-s-notesium-light span.cm-quote { color: #64748b; }
|
||||
.cm-s-notesium-light span.cm-formatting-quote { color: #64748b; }
|
||||
.cm-s-notesium-light span.cm-formatting-list-ul { color: #94a3b8; }
|
||||
.cm-s-notesium-light span.cm-url,
|
||||
.cm-s-notesium-light span.cm-link { color: #4338ca; text-decoration: none; }
|
||||
|
||||
.cm-links-hover span.cm-url:hover,
|
||||
.cm-links-hover span.cm-link:hover { cursor: pointer; text-decoration: underline; }
|
||||
.cm-fat-cursor .CodeMirror-cursor { background: #cbd5e1; max-width: 8px; }
|
||||
|
||||
.cm-conceal .CodeMirror span.cm-url,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-em,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-link,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-code,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-strong,
|
||||
.cm-conceal .CodeMirror span.cm-formatting-strikethrough { font-size: 0px; transition: font-size 0.1s ease-in-out; }
|
||||
.cm-unconceal .CodeMirror-activeline span,
|
||||
.cm-unconceal .CodeMirror-selectedline span { font-size: inherit !important; }
|
||||
|
||||
span.cm-hr { color: #94a3b8; }
|
||||
.cm-conceal span.cm-hr { color: transparent; background: #d1d5db; display: inline-block; width: 100%; line-height: 1px; }
|
||||
.cm-unconceal .CodeMirror-activeline span.cm-hr,
|
||||
.cm-unconceal .CodeMirror-selectedline span.cm-hr { color: #94a3b8; background: initial; display: initial; width: initial; line-height: initial;}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: inherit;
|
||||
z-index: 15;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px #d1d5db solid;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.CodeMirror-dialog input {
|
||||
border: none;
|
||||
outline: 0;
|
||||
background: 0 0;
|
||||
width: 40em;
|
||||
color: inherit;
|
||||
font-family: monospace;
|
||||
caret-color: black;
|
||||
}
|
||||
|
||||
.cm-s-notesium-light .CodeMirror-gutters { background-color: transparent; border-right: 0px; }
|
||||
.cm-s-notesium-light .CodeMirror-guttermarker-subtle { color: #d1d5db; cursor: pointer; }
|
||||
.cm-s-notesium-light .CodeMirror-guttermarker-subtle:hover { color: black; }
|
||||
.CodeMirror-foldgutter { width: 1.5em; }
|
||||
.CodeMirror-foldgutter-open:after,
|
||||
.CodeMirror-foldgutter-folded:after {
|
||||
content: "\203A";
|
||||
display: inline-block;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
margin-top: -3px;
|
||||
}
|
||||
.CodeMirror-foldgutter-open:after {
|
||||
margin-left: 1px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.cm-foldmarker {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 98%;
|
||||
background-color: #e4e4e790;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.cm-foldmarker:hover {
|
||||
background-color: #e4e4e7;
|
||||
}
|
||||
.cm-foldmarker-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-foldmarker-lines {
|
||||
padding-left: 10px;
|
||||
opacity: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body tabindex="-1">
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
const { createApp } = Vue
|
||||
import App from './app.js'
|
||||
import { notesiumState } from './state.js'
|
||||
const app = createApp(App)
|
||||
app.config.globalProperties.$notesiumState = notesiumState;
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
58
internal/notesium/web/app/link-tree.js
Normal file
58
internal/notesium/web/app/link-tree.js
Normal file
@@ -0,0 +1,58 @@
|
||||
var t = `
|
||||
<div class="text-sm my-1">
|
||||
<div class="flex justify-between">
|
||||
<div class="overflow-hidden truncate">
|
||||
<span class="cursor-pointer hover:font-bold text-gray-500 font-mono pr-2" v-text="expanded ? '-' : '+'" @click="toggle" ></span>
|
||||
<span class="cursor-pointer hover:underline text-indigo-700"
|
||||
v-text="title" :title="title + ' (line: ' + linenum + ')'"
|
||||
@click="$emit('note-open', filename, linenum)"></span>
|
||||
</div>
|
||||
<span v-if="direction" v-text="direction"
|
||||
:class="direction == 'incoming' ? 'bg-green-50 text-green-600 ring-green-500/10' : 'bg-yellow-50 text-yellow-600 ring-yellow-500/10'"
|
||||
class="mr-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset"></span>
|
||||
</div>
|
||||
|
||||
<div class="ml-5" v-if="expanded">
|
||||
<LinkTree v-for="child in children"
|
||||
:title="child.title" :filename="child.filename" :linenum="child.linenum" :direction="child.direction" :key="child.filename + child.linenum"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
80
internal/notesium/web/app/make.sh
Executable file
80
internal/notesium/web/app/make.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
fatal() { echo "Fatal: $*" 1>&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat<<EOF
|
||||
Usage: $0 COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
all handle vendor, tailwind
|
||||
vendor download, verify and concatenate vendor files
|
||||
tailwind [--watch] build tailwind.css
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
_vendor_files() {
|
||||
CM="https://github.com/alonswartz/notesium-cm5/releases/download"
|
||||
D3="https://cdnjs.cloudflare.com/ajax/libs/d3"
|
||||
cat<<EOF
|
||||
628497cb69df7b1d31236479cad68c9bb3f265060afd5506a0c004b394dfa47e https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js
|
||||
9571811ec12dbfe62834171349cf64bfa206ebd3d2519c16d2b3be4b5862b966 $CM/v5.65.18-2/notesium-cm5.min.js
|
||||
4d507d755e1d3188bd1e95d67b8bc9efd0094576135006fce68c5e9d44303061 $CM/v5.65.18-2/notesium-cm5.min.css
|
||||
d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c $D3/7.8.5/d3.min.js
|
||||
EOF
|
||||
}
|
||||
|
||||
_vendor_get_verify() {
|
||||
SRC="$1"
|
||||
DST="$2"
|
||||
HASH="$3"
|
||||
if [ -f "$DST" ]; then
|
||||
echo -n "$HASH $DST" | sha256sum --strict --check -
|
||||
return 0
|
||||
fi
|
||||
curl -qsL $SRC -o $DST.tmp
|
||||
echo -n "$HASH $DST.tmp" | sha256sum --strict --check -
|
||||
mv $DST.tmp $DST
|
||||
}
|
||||
|
||||
_vendor() {
|
||||
mkdir -p .vendor
|
||||
rm -f vendor.js vendor.css
|
||||
command -v curl >/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 "$@"
|
||||
122
internal/notesium/web/app/nav-tabs.js
Normal file
122
internal/notesium/web/app/nav-tabs.js
Normal file
@@ -0,0 +1,122 @@
|
||||
var t = `
|
||||
<TransitionGroup name="nav-tabs" tag="div"
|
||||
leave-active-class="transition-none"
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
move-class="transition-transform duration-250 ease-in-out"
|
||||
class="flex flex-nowrap max-w-full w-full h-9 overflow-x-hidden items-center px-2 mr-6">
|
||||
<div v-for="(tab, index) in getTabs" :key="tab.id" class="flex items-center h-full flex-1 min-w-0 max-w-[13rem]">
|
||||
<div :class="isActive(tab.id) ? 'text-gray-50' : 'text-transparent'" class="relative h-full">
|
||||
<svg class="absolute right-0 bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z"></path></svg>
|
||||
</div>
|
||||
<div
|
||||
@click="$emit('tab-activate', tab.id)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@drop="onDrop"
|
||||
@dragend="onDragEnd"
|
||||
:title="tab.titleHover"
|
||||
:class="isActive(tab.id) ? 'bg-gray-50 text-gray-800' : 'hover:bg-gray-100/75 hover:text-gray-700 text-gray-500'"
|
||||
class="flex items-center justify-between rounded-t-lg text-xs h-full pl-3 pr-2 cursor-pointer w-full truncate">
|
||||
<div class="flex items-center gap-2 truncate min-w-0">
|
||||
<span v-show="tab.isModified" class="inline-block h-2 w-2 rounded-full bg-yellow-400"></span>
|
||||
<span class="truncate" v-text="tab.title"></span>
|
||||
</div>
|
||||
<span @click.stop="handleClose(tab.id, tab.type)" class="hover:bg-gray-300 hover:rounded-full">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div :class="isActive(tab.id) ? 'text-gray-50' : 'text-transparent'" class="relative h-full">
|
||||
<svg class="absolute bottom-0" fill="currentColor" width="7" height="7"><path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z"></path></svg>
|
||||
</div>
|
||||
<span :class="!isActive(tab.id) ? 'text-gray-300' : 'text-transparent'" class="z-1 -mr-1 -mt-[3px]">|</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
137
internal/notesium/web/app/note-sidebar.js
Normal file
137
internal/notesium/web/app/note-sidebar.js
Normal file
@@ -0,0 +1,137 @@
|
||||
var t = `
|
||||
<Pane name="noteSidebar" :defaultWidth="384" :minWidth="245" direction="left">
|
||||
<aside class="h-full overflow-y-auto mt-2 mr-2 rounded-t-lg border border-gray-200 bg-white">
|
||||
|
||||
<div class="flex gap-x-5 gap-y-2 p-2 border-b sticky top-0 z-10 bg-white">
|
||||
<button type="button" :disabled="!note.isModified" @click="$emit('note-save')"
|
||||
:class="note.isModified ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-300 text-gray-400'"
|
||||
class="w-28 rounded px-2 py-1.5 text-xs shadow-sm">Save</button>
|
||||
<div class="flex gap-x-5 gap-y-2 mt-0 ml-auto items-center justify-center">
|
||||
<span title="links" @click="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="graph" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="delete note" @click="$emit('note-delete', note.Filename, note.Mtime)"
|
||||
class="cursor-pointer text-gray-400 hover:text-red-700">
|
||||
<Icon name="outline-trash" size="h-4 w-4" />
|
||||
</span>
|
||||
<a title="open via xdg" :href="'notesium://' + note.Path"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="outline-external-link" size="h-4 w-4" />
|
||||
</a>
|
||||
<span title="close" @click="$notesiumState.showNoteSidebar=false"
|
||||
class="cursor-pointer text-gray-400 hover:text-gray-700">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="m-2 grid grid-cols-3 gap-2">
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Lines"></dd>
|
||||
<dt class="text-sm text-gray-400">Lines</dt>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Words"></dd>
|
||||
<dt class="text-sm text-gray-400">Words</dt>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-2">
|
||||
<dd class="text-sm font-semibold text-gray-700" v-text="note.Chars"></dd>
|
||||
<dt class="text-sm text-gray-400">Chars</dt>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl class="m-2 grid grid-cols-1 gap-2">
|
||||
<div class="overflow-hidden rounded-lg bg-gray-50 px-4 py-3 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<dd class="text-sm font-semibold tracking-tight text-gray-700" v-text="formattedDate(note.Mtime)"></dd>
|
||||
<dt class="text-sm text-gray-400 hover:underline cursor-pointer flex items-center space-x-2"
|
||||
title="list notes modified same day"
|
||||
@click="$emit('finder-open', '/api/raw/list?color=true&date=2006-01-02&prefix=mtime&sort=mtime', note.Mtime.split('T')[0] + ' ')">
|
||||
<span>Modified</span>
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-3 w-3" />
|
||||
</dt>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<dd class="text-sm font-semibold tracking-tight text-gray-700" v-text="formattedDate(note.Ctime)"></dd>
|
||||
<dt class="text-sm text-gray-400 hover:underline cursor-pointer flex items-center space-x-2"
|
||||
title="list notes created same day"
|
||||
@click="$emit('finder-open', '/api/raw/list?color=true&date=2006-01-02&prefix=ctime&sort=ctime', note.Ctime.split('T')[0] + ' ')">
|
||||
<span>Created</span>
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-3 w-3" />
|
||||
</dt>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="m-2 overflow-hidden rounded-lg bg-gray-50 pl-4 pr-2 py-3">
|
||||
<div class="flex justify-between items-center mt-1 mb-2 text-sm">
|
||||
<h3 class="leading-6 font-semibold tracking-tight text-gray-700">Links incoming</h3>
|
||||
<span class="text-gray-400 mr-2" v-text="countIncomingLinks"></span>
|
||||
</div>
|
||||
<LinkTree v-for="link in sortedIncomingLinks"
|
||||
:title="link.Title" :filename="link.Filename" :linenum="link.LineNumber" :key="link.Filename + link.LineNumber"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
|
||||
<div class="flex justify-between items-center mt-4 mb-2 text-sm">
|
||||
<h3 class="leading-6 font-semibold tracking-tight text-gray-700">Links outgoing</h3>
|
||||
<span class="text-gray-400 mr-2" v-text="countOutgoingLinks"></span>
|
||||
</div>
|
||||
<LinkTree v-for="link in existingOutgoingLinks"
|
||||
:title="link.Title" :filename="link.Filename" linenum="1" :key="link.Filename + link.LineNumber"
|
||||
@note-open="(...args) => $emit('note-open', ...args)" />
|
||||
|
||||
<div v-for="link in danglingOutgoingLinks" class="flex justify-between text-sm text-red-700 my-1">
|
||||
<div class="overflow-hidden truncate">
|
||||
<span class="font-mono pr-2">!</span>
|
||||
<span class="cursor-pointer hover:underline"
|
||||
@click="$emit('note-open', note.Filename, link.LineNumber)"
|
||||
v-text="link.Filename + ' (line ' + link.LineNumber + ')'">
|
||||
</span>
|
||||
</div>
|
||||
<span class="mr-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset bg-red-50 ring-red-500/10">dangling</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
64
internal/notesium/web/app/note-statusbar.js
Normal file
64
internal/notesium/web/app/note-statusbar.js
Normal file
@@ -0,0 +1,64 @@
|
||||
var t = `
|
||||
<div class="flex h-7 justify-between items-center justify-items-center bg-gray-200 text-xs">
|
||||
<div class="flex h-full">
|
||||
<span v-if="vimMode" class="pl-3 pr-4 pt-1.5 pb-1 uppercase font-semibold text-white rounded-r-full" :class="vimModeCls" v-text="vimModeText"></span>
|
||||
<span v-else class="pl-3 pr-4 pt-1.5 pb-1 uppercase text-gray-500 truncate" v-text="hasFocus ? 'focused' : 'not focused'"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full bg-gray-900/5 rounded-l-full text-gray-500">
|
||||
<div class="flex h-full space-x-5 px-4 items-center justify-items-center">
|
||||
<span title="toggle vim mode" @click="$notesiumState.editorVimMode = !$notesiumState.editorVimMode"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorVimMode ? 'mode:vim' : 'mode:default'" />
|
||||
</div>
|
||||
<div class="flex h-full space-x-5 px-4 items-center justify-items-center bg-gray-300 rounded-l-full border-l border-gray-400">
|
||||
<span title="line wrapping" @click="$notesiumState.editorLineWrapping = !$notesiumState.editorLineWrapping"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorLineWrapping ? 'wrap' : 'nowrap'" />
|
||||
<span title="conceal formatting" @click="$notesiumState.editorConcealFormatting = !$notesiumState.editorConcealFormatting"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorConcealFormatting ? 'conceal' : 'noconceal'" />
|
||||
<span title="fold gutter" @click="$notesiumState.editorFoldGutter = !$notesiumState.editorFoldGutter"
|
||||
class="cursor-pointer hover:text-gray-700" v-text="$notesiumState.editorFoldGutter ? 'fold' : 'nofold'" />
|
||||
<template v-if="!note.ghost && !$notesiumState.showNoteSidebar">
|
||||
<span title="incoming links" class="cursor-pointer hover:text-gray-700 -mb-px"
|
||||
@click="$emit('finder-open', '/api/raw/links?color=true&incoming=true&filename=' + note.Filename)">
|
||||
{{note.IncomingLinks?.length || 0}}↙
|
||||
</span>
|
||||
<span title="outgoing links" class="cursor-pointer hover:text-gray-700 -mb-px"
|
||||
@click="$emit('finder-open', '/api/raw/links?color=true&outgoing=true&filename=' + note.Filename)">
|
||||
{{note.OutgoingLinks?.length || 0}}↗
|
||||
</span>
|
||||
<span title="links" @click="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)"
|
||||
class="cursor-pointer hover:text-gray-700">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
class="cursor-pointer hover:text-gray-700">
|
||||
<Icon name="graph" size="h-3 w-3" />
|
||||
</span>
|
||||
<span title="delete note" @click="$emit('note-delete', note.Filename, note.Mtime)"
|
||||
class="cursor-pointer hover:text-red-700">
|
||||
<Icon name="outline-trash" size="h-4 w-4" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
333
internal/notesium/web/app/note.js
Normal file
333
internal/notesium/web/app/note.js
Normal file
@@ -0,0 +1,333 @@
|
||||
var t = `
|
||||
<div class="relative flex h-full">
|
||||
<div class="flex flex-col grow overflow-y-auto">
|
||||
<div ref="codemirror"
|
||||
@keydown.[.exact="handleLeftBracket"
|
||||
class="h-full p-2 pr-1 pb-px cm-links-hover"
|
||||
:class="{'cm-conceal cm-unconceal': $notesiumState.editorConcealFormatting, 'cm-fat-cursor': fatCursor}"></div>
|
||||
<NoteStatusbar :note=note :vimMode=vimMode :hasFocus=hasFocus
|
||||
@note-delete="(...args) => $emit('note-delete', ...args)"
|
||||
@finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
</div>
|
||||
|
||||
<div v-if="!$notesiumState.showNoteSidebar || note.ghost" class="absolute right-0 mt-2 mr-4 h-7 z-10 inline-flex items-center">
|
||||
<button type="button" :disabled="!this.note.isModified" @click="handleSave()"
|
||||
:class="this.note.isModified ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-300 text-gray-400'"
|
||||
class="rounded px-10 py-1.5 text-xs">Save</button>
|
||||
<span v-if="!note.ghost" title="sidebar" @click="$notesiumState.showNoteSidebar=true"
|
||||
class="ml-2 cursor-pointer text-gray-400 hover:text-gray-600">
|
||||
<Icon name="outline-information-circle" size="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NoteSidebar v-if="$notesiumState.showNoteSidebar && !note.ghost" :note=note
|
||||
@note-save="handleSave()"
|
||||
@note-open="(...args) => $emit('note-open', ...args)"
|
||||
@note-delete="(...args) => $emit('note-delete', ...args)"
|
||||
@finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
|
||||
<Finder v-if="showFinder" uri="/api/raw/list?sort=mtime" small=true @finder-selection="handleFinderSelection" />
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
71
internal/notesium/web/app/pane.js
Normal file
71
internal/notesium/web/app/pane.js
Normal file
@@ -0,0 +1,71 @@
|
||||
var t = `
|
||||
<div class="relative flex-none" :style="{ width: paneWidth + 'px' }">
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-show="resizing" v-text="paneWidth + 'px'" class="absolute bottom-0 right-0 p-4 text-gray-400 text-xs"></div>
|
||||
<div @dblclick="setDefaultWidth()" @mousedown="startResize"
|
||||
:class="direction == 'right' ? 'left-full pl-1.5 mr-1.5' : 'right-full pr-1.5 ml-1.5'"
|
||||
class="z-50 absolute group inset-y-0 cursor-ew-resize flex items-center">
|
||||
<div class="h-8 w-1 rounded-full group-hover:bg-gray-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
62
internal/notesium/web/app/periodic.js
Normal file
62
internal/notesium/web/app/periodic.js
Normal file
@@ -0,0 +1,62 @@
|
||||
var t = `
|
||||
<div v-show="showDatePicker" @keyup.esc="$emit('periodic-close')" @click="$emit('periodic-close')" class="fixed inset-0 z-40" aria-hidden="true"></div>
|
||||
<div v-if="showDatePicker" class="block absolute left-16 z-50 w-64 pt-3">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200 p-3
|
||||
before:absolute before:bottom-0 before:top-0 before:-left-2 before:bg-white before:border-l before:border-b before:border-gray-300
|
||||
before:w-4 before:h-4 before:rotate-45 before:-z-1 before:my-auto">
|
||||
<DatePicker :dottedDates="periodicNoteDates" @date-selected="(date) => periodicNoteDate = date" />
|
||||
<div class="flex space-x-2 items-center justify-items-center">
|
||||
<div @click="$emit('note-weekly', periodicNoteDate); $emit('periodic-close')"
|
||||
class="py-1 w-full text-xs text-center rounded-md hover:cursor-pointer hover:text-white bg-emerald-100 hover:bg-emerald-600 text-emerald-600">
|
||||
Weekly note
|
||||
</div>
|
||||
<div @click="$emit('note-daily', periodicNoteDate); $emit('periodic-close')"
|
||||
class="py-1 w-full text-xs text-center rounded-md hover:cursor-pointer hover:text-white bg-indigo-100 hover:bg-indigo-600 text-indigo-600">
|
||||
Daily note
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
82
internal/notesium/web/app/preview.js
Normal file
82
internal/notesium/web/app/preview.js
Normal file
@@ -0,0 +1,82 @@
|
||||
var t = `
|
||||
<div :class="{ 'cm-links-hover': clickableLinks }" class="h-full cm-conceal" ref="preview"></div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
84
internal/notesium/web/app/ribbon.js
Normal file
84
internal/notesium/web/app/ribbon.js
Normal file
@@ -0,0 +1,84 @@
|
||||
var t = `
|
||||
<div class="flex-none h-full drop-shadow-md w-12 px-2 divide-y border-r divide-gray-600 border-gray-600 bg-gray-700 text-gray-400">
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-0.5">
|
||||
<span title="New note" @click="$emit('note-new', '')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-plus" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span title="Labels panel" @click="$notesiumState.showLabelsPanel=!$notesiumState.showLabelsPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showLabelsPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-tag" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Notes list panel" @click="$notesiumState.showNotesPanel=!$notesiumState.showNotesPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showNotesPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-queue-list" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Graph panel" @click="$notesiumState.showGraphPanel=!$notesiumState.showGraphPanel"
|
||||
:class="{'text-gray-100': $notesiumState.showGraphPanel}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="graph" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Periodic notes" @click="$emit('periodic-open')"
|
||||
:class="{'text-gray-100': showPeriodic}"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-calendar" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span title="Search notes" @click="$emit('finder-open', '/api/raw/lines?color=true&prefix=title')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-magnifying-glass" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List notes" @click="$emit('finder-open', '/api/raw/list?color=true&prefix=label&sort=alpha')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-bars-three-bottom-left" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List notes (modified)" @click="$emit('finder-open', '/api/raw/list?color=true&prefix=mtime&sort=mtime')"
|
||||
class="p-2 pb-1 pr-1 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-bars-arrow-down" size="h-5 w-5" />
|
||||
</span>
|
||||
<span title="List links" @click="$emit('finder-open', '/api/raw/links?color=true')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="mini-arrows-right-left" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="List broken links" @click="$emit('finder-open', '/api/raw/links?color=true&dangling=true')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="outline-link-slash" size="h-4 w-4" />
|
||||
</span>
|
||||
<span title="Graph view" @click="$emit('graph-open')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<Icon name="graph" size="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-items-center py-2 space-y-2">
|
||||
<span :title="updateAvailable ? 'An update is available' : 'Settings'" @click="$emit('settings-open')"
|
||||
class="p-2 cursor-pointer rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<div :class="{'rounded-full -m-1 p-1 bg-red-500 text-gray-100': updateAvailable}">
|
||||
<Icon name="outline-adjustments-horizontal" size="h-5 w-5" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
157
internal/notesium/web/app/settings-about.js
Normal file
157
internal/notesium/web/app/settings-about.js
Normal file
@@ -0,0 +1,157 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<div class="flex flex-wrap">
|
||||
<dl class="flex w-full px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center justify-between">
|
||||
<dt class="text-xs leading-6 text-gray-900 font-semibold">Software update</dt>
|
||||
<dd v-if="versionCheck.inprogress" class="mt-1 text-blue-700"><Icon name="spinner-circle" size="h-4 w-4" /></dd>
|
||||
<dd v-else class="text-xs leading-6 text-blue-700 hover:text-blue-600 hover:cursor-pointer" @click="$emit('version-check')">Check for updates</dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Runtime version</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="runtime.version || 'unknown'"></dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Latest release</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="versionCheck.latestVersion || 'unknown'"></dd>
|
||||
</dl>
|
||||
<dl class="px-6 py-2 flex w-full flex-none justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Last check</dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900" v-text="formattedDate(versionCheck.date) || 'unknown'"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="versionCheck.error" class="font-mono text-xs text-red-600 px-6 py-2" v-text="versionCheck.error"></div>
|
||||
<div v-else-if="versionCheck.comparison == '-1'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-blue-700 bg-blue-50">
|
||||
<a class="text-xs font-bold leading-6 hover:underline" target="_blank" href="https://github.com/alonswartz/notesium/releases">
|
||||
A new release is available <span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else-if="versionCheck.comparison == '0'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-green-700 bg-green-50">
|
||||
<span class="text-xs font-medium leading-6">You are using the latest version</span>
|
||||
</div>
|
||||
<div v-else-if="versionCheck.comparison == '1'" class="w-full py-2 mx-auto border-t border-gray-200 text-center text-yellow-700 bg-yellow-50">
|
||||
<span class="text-xs font-medium leading-6">You are using a newer version than the latest release</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900">Resources</dt>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<a target="_blank" href="https://www.notesium.com"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Website</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/alonswartz/notesium"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Github</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
<a target="_blank" :href="issueUrl"
|
||||
class="flex w-full px-6 py-2 flex-none items-center justify-items-center justify-between hover:bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">Report an issue</dt>
|
||||
<dd><Icon name="outline-external-link" size="h-4 w-4" /></dd>
|
||||
</a>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900 hover:cursor-pointer" @click="getRuntime()" title="click to refresh">Runtime</dt>
|
||||
</div>
|
||||
<template v-for="(val, key) in runtime" :key="key">
|
||||
<template v-if="typeof val === 'object' && val !== null">
|
||||
<details class="text-xs flex w-full flex-col bg-gray-50">
|
||||
<summary class="flex w-full pl-6 pr-5 py-2 flex-none items-center justify-between hover:bg-gray-100 hover:cursor-pointer focus:outline-none">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900" v-text="key"></dt>
|
||||
<dd><Icon name="chevron-right" size="h-4 w-4" /></dd>
|
||||
</summary>
|
||||
<div class="ml-6 pr-6 border-l-2 border-dotted border-gray-200">
|
||||
<div v-for="(subVal, subKey) in val" :key="key + subKey" class="pl-3 py-2 flex w-full flex-none items-center justify-between">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="subKey"></dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" :title="subVal" v-text="subVal"></dd>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="px-6 py-2 flex w-full flex-none items-center justify-between bg-gray-50">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="key"></dt>
|
||||
<dd class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" :title="val" v-text="val"></dd>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<details class="text-xs flex w-full flex-col bg-gray-50">
|
||||
<summary class="flex w-full pl-6 pr-5 py-2 flex-none items-center justify-between hover:bg-gray-100 hover:cursor-pointer focus:outline-none">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900">state</dt>
|
||||
<dd><Icon name="chevron-right" size="h-4 w-4" /></dd>
|
||||
</summary>
|
||||
<div class="ml-6 pr-6 border-l-2 border-dotted border-gray-200">
|
||||
<div v-for="(stateVal, stateKey) in $notesiumState" :key="stateKey" class="pl-3 py-2 flex w-full flex-none items-center justify-between">
|
||||
<dt class="text-xs font-medium leading-6 text-gray-900 whitespace-nowrap" v-text="stateKey"></dt>
|
||||
<dd class="flex space-x-3 -mr-1">
|
||||
<span class="text-xs font-mono leading-6 text-gray-900 truncate ml-10" v-text="stateVal"></span>
|
||||
<span class="cursor-pointer text-gray-400 hover:text-gray-700 mt-0.5" @click="delete $notesiumState[stateKey]">
|
||||
<Icon name="mini-x-mark" size="h-4 w-4" />
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
160
internal/notesium/web/app/settings-editor.js
Normal file
160
internal/notesium/web/app/settings-editor.js
Normal file
@@ -0,0 +1,160 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<div class="px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="settings.title"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in settings.entries" :key="entry.name" class="px-6 py-2 flex w-full flex-none items-center justify-center justify-between">
|
||||
<span class="text-xs text-gray-900 font-medium leading-6 mt-1" v-text="entry.title"></span>
|
||||
<button @click="$notesiumState[entry.name] = !$notesiumState[entry.name]" type="button" role="switch"
|
||||
:class="$notesiumState[entry.name] ? 'bg-indigo-600' : 'bg-gray-200'"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2">
|
||||
<span aria-hidden="true"
|
||||
:class="$notesiumState[entry.name] ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="keymap in keymaps" :key="keymap.name" class="rounded-md border border-gray-200">
|
||||
<div class="px-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<details v-if="keymap.info" class="w-full">
|
||||
<summary class="flex w-full items-center py-px justify-between hover:cursor-pointer focus:outline-none">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="keymap.title"></span>
|
||||
<span class="text-gray-600 hover:text-gray-900"><Icon name="outline-information-circle" size="h-4 w-4" /></span>
|
||||
</summary>
|
||||
<div class="py-3 text-xs text-gray-700 space-y-2 leading-6">
|
||||
<template v-if="keymap.name == 'vim'">
|
||||
<p>Vim mode attempts to emulate the most useful features of Vim
|
||||
as faithfully as possible, but is not a complete implementation.
|
||||
It does however feature the following:</p>
|
||||
<ul class="list-disc pl-4 space-y-2">
|
||||
<li>All common motions and operators, including text objects</li>
|
||||
<li>Operator motion orthogonality</li>
|
||||
<li>Commands for write and quit (:w :wq :q :q!)</li>
|
||||
<li>Visual mode - characterwise, linewise, blockwise</li>
|
||||
<li>Full macro support (q @)</li>
|
||||
<li>Folding support (za zo zc zR zM)</li>
|
||||
<li>Incremental highlighted search (/ ? # * g# g*)</li>
|
||||
<li>Search/replace with confirm (:substitute :%s)</li>
|
||||
<li>Search history</li>
|
||||
<li>Jump lists (C-o C-i)</li>
|
||||
<li>Sort (:sort)</li>
|
||||
<li>Marks (` ')</li>
|
||||
<li>Cross-buffer yank/paste</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="keymap.name == 'table'">
|
||||
<p>The editor will recognize when the cursor is placed within a
|
||||
table structure (identified by lines starting with the |
|
||||
character), and provide formatting and navigation.</p>
|
||||
<ul class="list-disc pl-4 space-y-2">
|
||||
<li><b>Automatic table formatting:</b> Pressing Tab not only
|
||||
navigates through the table but also automatically formats it.
|
||||
This includes adjusting cell padding to align text according
|
||||
to the column specifications defined in the header row.</li>
|
||||
<li><b>Column alignment:</b> The alignment for each column is
|
||||
determined by the syntax used in the header separator row
|
||||
(--- left, :---: center, ---: right).</li>
|
||||
<li><b>Dynamic column adjustment:</b> If the cursor is at the
|
||||
end of a row and Tab is pressed, a new column will be added.
|
||||
When the cursor is on the header row, pressing Tab ensures the
|
||||
header separator row exists and matches the column count of
|
||||
the header, adjusting as necessary.</li>
|
||||
<li><b>Concealment support:</b> When concealment is enabled,
|
||||
the formatting logic takes this into account, calculating the
|
||||
maximum length of each column without the concealed text,
|
||||
ensuring a visually consistent table layout.</li>
|
||||
<li><b>Navigation:</b> Move across table cells and rows with
|
||||
the provided keybindings.</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
<span v-else class="text-xs font-semibold leading-6 text-gray-900" v-text="keymap.title"></span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in keymap.entries" class="w-full flex px-6 py-2 text-xs items-center justify-items-center justify-between">
|
||||
<div class="flex">
|
||||
<span v-if="entry[2]" class="text-gray-600 font-medium w-20 mt-1" v-text="entry[2]"></span>
|
||||
<span class="text-gray-900 font-medium mt-1" v-text="entry[1]"></span>
|
||||
</div>
|
||||
<span class="text-gray-900 font-mono bg-gray-200 rounded-md p-2 pb-1" v-text="entry[0]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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 <char>', 'Global keybinds passthrough', 'normal'],
|
||||
['ge | gx', 'Open link under cursor', 'normal'],
|
||||
['z<char> | 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
|
||||
}
|
||||
65
internal/notesium/web/app/settings-keybinds.js
Normal file
65
internal/notesium/web/app/settings-keybinds.js
Normal file
@@ -0,0 +1,65 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
<div v-for="section in sections" :key="section.name" class="rounded-md border border-gray-200">
|
||||
<div class="pl-6 py-2 bg-gray-100 border-b border-gray-200 items-center justify-items-center">
|
||||
<span class="text-xs font-semibold leading-6 text-gray-900" v-text="section.title"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="entry in section.entries" class="w-full flex px-6 py-2 text-xs items-center justify-items-center justify-between">
|
||||
<div class="flex">
|
||||
<span v-if="entry[2]" class="text-gray-600 font-medium w-20 mt-1" v-text="entry[2]"></span>
|
||||
<span class="text-gray-900 font-medium mt-1" v-text="entry[1]"></span>
|
||||
</div>
|
||||
<span class="text-gray-900 font-mono bg-gray-200 rounded-md p-2 pb-1" v-text="entry[0]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
61
internal/notesium/web/app/settings-stats.js
Normal file
61
internal/notesium/web/app/settings-stats.js
Normal file
@@ -0,0 +1,61 @@
|
||||
var t = `
|
||||
<div class="h-full w-full overflow-y-scroll pb-6">
|
||||
<div class="relative flex-1 px-6 mt-2 space-y-6">
|
||||
|
||||
<div class="rounded-md border border-gray-200">
|
||||
<dl class="flex flex-wrap">
|
||||
<div class="flex-auto pl-6 py-2 bg-gray-100 border-b border-gray-200">
|
||||
<dt class="text-xs font-semibold leading-6 text-gray-900">Counts</dt>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 w-full">
|
||||
<div v-for="stat in stats" :key="stat[0]" :title="stat[2]" @click="stat[3] ? $emit('finder-open', stat[3]) : undefined"
|
||||
:class="{'hover:bg-gray-50 cursor-pointer': stat[3]}"
|
||||
class="px-6 py-2 flex w-full flex-none items-center justify-center justify-between">
|
||||
<dt :class="stat[3] ? 'text-gray-900' : 'text-gray-600'" class="text-xs font-medium leading-6 flex" v-text="stat[1]"></dt>
|
||||
<dd :class="stat[3] ? 'text-gray-900' : 'text-gray-600'" class="text-xs font-mono leading-6" v-text="counts[stat[0]] || 'unknown'"></dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
54
internal/notesium/web/app/settings.js
Normal file
54
internal/notesium/web/app/settings.js
Normal file
@@ -0,0 +1,54 @@
|
||||
var t = `
|
||||
<div @keyup.esc="$emit('settings-close');" class="relative inset-0 z-50" aria-labelledby="settings" role="dialog" aria-modal="true">
|
||||
<div @click="$emit('settings-close');" class="fixed inset-0" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16">
|
||||
<div class="pointer-events-auto">
|
||||
<div class="flex h-full bg-white shadow-xl">
|
||||
<div class="flex-col h-full w-48 bg-gray-100 border-x border-gray-200">
|
||||
<ul class="space-y-1 cursor-pointer mt-2 p-2 text-sm">
|
||||
<li v-for="section in sections"
|
||||
@click="active=section[0]" :class="{'bg-gray-200': active == section[0] }"
|
||||
class="flex justify-between p-2 rounded-md text-gray-800 hover:bg-gray-200">
|
||||
<span class="overflow-hidden truncate pr-2" v-text="section[1]" />
|
||||
<span class="text-gray-500 hover:text-gray-100"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="h-full w-[40rem] pr-1 mt-2">
|
||||
<KeyBinds v-if="active == 'keybinds'" />
|
||||
<Editor v-else-if="active == 'editor'" />
|
||||
<Stats v-else-if="active == 'stats'" @finder-open="(...args) => $emit('finder-open', ...args)" />
|
||||
<About v-else-if="active == 'about'" @version-check="(...args) => $emit('version-check', ...args)" :versionCheck=versionCheck />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
387
internal/notesium/web/app/sidepanel.js
Normal file
387
internal/notesium/web/app/sidepanel.js
Normal file
@@ -0,0 +1,387 @@
|
||||
var t = `
|
||||
<Pane v-if="$notesiumState.showLabelsPanel" name="labelsPanel" :defaultWidth="195" :minWidth="100">
|
||||
<div class="h-full overflow-y-auto bg-gray-700 text-gray-400 text-sm font-medium dark-scroll border-r border-gray-600">
|
||||
<ul class="space-y-1 cursor-pointer px-2">
|
||||
<li v-for="label in sortedLabelNotes" :key="label.Filename"
|
||||
@click="$notesiumState.showNotesPanel ? query='label:'+label.Title+' ' : $emit('finder-open', '/api/raw/links?color=true&filename=' + label.Filename)"
|
||||
class="group flex justify-between p-2 rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<span class="overflow-hidden truncate pr-2" v-text="label.Title" />
|
||||
<span class="group-hover:hidden text-gray-500" v-text="label.LinkedNotesCount" />
|
||||
<span class="hidden group-hover:block text-gray-500 hover:text-gray-100" @click.stop="$emit('note-open', label.Filename)">↗</span>
|
||||
</li>
|
||||
|
||||
<li title="notes with 1-word titles are considered labels"
|
||||
class="flex items-center justify-items-center h-9 pl-2 pr-1 rounded-md hover:text-gray-100 hover:bg-gray-600">
|
||||
<input class="h-full w-full text-white hover:placeholder:text-gray-300 placeholder:text-gray-500 bg-transparent focus:outline-none text-sm"
|
||||
@keydown.space.prevent
|
||||
@keyup.esc="newLabel=''; $refs.newLabelInput.blur()"
|
||||
@keyup.enter="createNewLabelNote()"
|
||||
v-model="newLabel" ref="newLabelInput" placeholder="new label..." type="text" autocomplete="off" spellcheck="false" />
|
||||
<div class="flex items-center text-gray-500">
|
||||
<Icon v-if="!newLabel" name="outline-plus" size="h-4 w-4" @click="$refs.newLabelInput.focus()" class="cursor-pointer hover:text-gray-200" />
|
||||
<Icon v-if="newLabelStatus.isValid" name="mini-check" size="h-4 w-4" @click="createNewLabelNote()" class="text-green-400" />
|
||||
<span v-if="newLabelStatus.error" v-text="newLabelStatus.error" class="text-red-400 text-xs whitespace-nowrap mt-1"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<Pane v-if="$notesiumState.showNotesPanel" name="notesPanel" :defaultWidth="380" :minWidth="100"
|
||||
:class="{'dark border-none': $notesiumState.notesPanelDarkMode}" class="border-r border-gray-200">
|
||||
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300 delay-200"
|
||||
leave-active-class="transition duration-200">
|
||||
<div v-if="previewFilename" class="absolute right-0 top-0 z-50">
|
||||
<div class="relative">
|
||||
<div class="absolute origin-top-right top-14 left-6 w-[40rem] h-[40rem] pl-4 py-4 rounded-lg shadow-2xl bg-white border border-gray-300
|
||||
before:absolute before:bottom-0 before:top-0 before:-left-2 before:bg-white before:border-l before:border-b before:border-gray-300
|
||||
before:w-4 before:h-4 before:rotate-45 before:-z-1 before:my-auto">
|
||||
<Preview :filename="previewFilename" appendIncomingLinks=true />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex items-center justify-items-center h-9 border-b border-gray-200 bg-gray-100 dark:bg-gray-700 dark:border-gray-600">
|
||||
<input ref="queryInput" v-model="query" placeholder="filter..." autocomplete="off" spellcheck="false"
|
||||
@keyup.esc="query = ''; $refs.queryInput.blur();"
|
||||
class="h-full w-full px-4 ring-0 border-none focus:outline-none text-sm placeholder:text-gray-400
|
||||
text-gray-900 bg-gray-100 dark:text-gray-100 dark:bg-gray-700" />
|
||||
|
||||
<div class="inline-flex items-center justify-items-center mt-3 m-2 h-full space-x-4">
|
||||
<div v-show="query" @click="query = ''" title="clear"
|
||||
class="-mt-1 pr-2 cursor-pointer border-r border-gray-300 text-gray-400 hover:text-gray-700 dark:border-gray-500 dark:hover:text-gray-300">
|
||||
<Icon name="mini-x-mark" size="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div class="relative group inline-block text-left">
|
||||
<span title="labels" class="cursor-pointer text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200">
|
||||
<Icon name="outline-tag" size="h-5 w-5" class="pb-1" />
|
||||
</span>
|
||||
<div class="hidden group-hover:block absolute right-0 z-50 w-64 pt-3 -mt-1 origin-top-right">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200">
|
||||
<ul class="text-sm divide-y divide-gray-100">
|
||||
<template v-if="$notesiumState.showLabelsPanel || ($notesiumState.notesPanelCompact && $notesiumState.notesPanelCompactLabels)">
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer" @click="$notesiumState.sidePanelSortLabels='title'">
|
||||
<span class="text-gray-600">Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'title'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortLabels='links'">
|
||||
<span class="text-gray-600">Linked notes count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'links'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'title'"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Link count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortLabels == 'links'"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="pt-px bg-gray-200"></li>
|
||||
<li v-if="$notesiumState.notesPanelCompact" class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.notesPanelCompactLabels=!$notesiumState.notesPanelCompactLabels">
|
||||
<span class="text-gray-600">Labels tree</span>
|
||||
<span v-show="$notesiumState.notesPanelCompactLabels" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li v-else class="flex items-center justify-between p-2 text-gray-300">
|
||||
<span>Labels tree</span>
|
||||
<span v-show="$notesiumState.notesPanelCompactLabels"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.showLabelsPanel=!$notesiumState.showLabelsPanel">
|
||||
<span class="text-gray-600">Labels panel</span>
|
||||
<span v-show="$notesiumState.showLabelsPanel" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<template v-if="!$notesiumState.showLabelsPanel">
|
||||
<li class="pt-px bg-gray-200"></li>
|
||||
<li class="flex items-center justify-items-center p-2" title="notes with 1-word titles are considered labels">
|
||||
<input class="h-full w-full bg-transparent focus:outline-none text-sm placeholder:text-gray-400 text-gray-800"
|
||||
@keydown.space.prevent
|
||||
@keyup.esc="newLabel=''; $refs.newLabelInput.blur()"
|
||||
@keyup.enter="createNewLabelNote()"
|
||||
v-model="newLabel" ref="newLabelInput" placeholder="New label..." type="text" autocomplete="off" spellcheck="false" />
|
||||
<div class="flex items-center cursor-pointer">
|
||||
<Icon v-if="!newLabel" name="outline-plus" size="h-5 w-5" @click="$refs.newLabelInput.focus()" class="text-gray-400 hover:text-gray-600" />
|
||||
<Icon v-if="newLabelStatus.isValid" name="mini-check" size="h-5 w-5" @click="createNewLabelNote()" class="text-green-500" />
|
||||
<span v-if="newLabelStatus.error" v-text="newLabelStatus.error" class="cursor-default text-red-700 text-xs whitespace-nowrap mt-1"></span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group inline-block text-left">
|
||||
<span title="sort & density" class="cursor-pointer text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200">
|
||||
<Icon name="outline-bars-arrow-down" size="h-5 w-5" />
|
||||
</span>
|
||||
<div class="hidden group-hover:block absolute right-0 z-50 w-64 pt-3 -mt-1 origin-top-right">
|
||||
<div class="rounded-md bg-white shadow-md border border-gray-200">
|
||||
<ul class="divide-y divide-gray-100 text-sm">
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='title'">
|
||||
<span class="text-gray-600">Title</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'title'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='mtime'">
|
||||
<span class="text-gray-600">Modified</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'mtime'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='ctime'">
|
||||
<span class="text-gray-600">Created</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'ctime'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.sidePanelSortNotes='links'">
|
||||
<span class="text-gray-600">Linked notes count</span>
|
||||
<span v-show="$notesiumState.sidePanelSortNotes == 'links'" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="bg-gray-200 pt-px"></li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.notesPanelCompact=true">
|
||||
<span class="text-gray-600">Compact view</span>
|
||||
<span v-show="$notesiumState.notesPanelCompact" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50" @click="$notesiumState.notesPanelCompact=false">
|
||||
<span class="text-gray-600">Detailed view</span>
|
||||
<span v-show="!$notesiumState.notesPanelCompact" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
<li class="bg-gray-200 pt-px"></li>
|
||||
<li class="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="$notesiumState.notesPanelDarkMode=!$notesiumState.notesPanelDarkMode">
|
||||
<span class="text-gray-600">Dark mode</span>
|
||||
<span v-show="$notesiumState.notesPanelDarkMode" class="text-indigo-500"><Icon name="mini-check" size="h-5 w-5" /></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-full flex flex-col justify-between overflow-y-scroll dark:bg-gray-700" :class="{'dark-scroll': $notesiumState.notesPanelDarkMode}">
|
||||
|
||||
<ul v-if="$notesiumState.notesPanelCompact" class="mt-2 text-sm">
|
||||
<li v-if="$notesiumState.notesPanelCompactLabels" v-for="note in filteredLabelNotes" :key="'label-' + note.Filename">
|
||||
<details class="cursor-pointer [&_.rotate-on-open]:open:rotate-90">
|
||||
<summary class="group flex items-center justify-between justify-items-center list-none py-1.5 pl-2
|
||||
rounded-r-2xl focus:outline-none text-gray-900 hover:bg-indigo-50 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<div class="flex items-center justify-center gap-x-2 truncate">
|
||||
<div class="text-gray-400 dark:text-gray-500 rotate-on-open">
|
||||
<Icon name="chevron-right" size="h-5 w-5" />
|
||||
</div>
|
||||
<span class="flex space-x-2 overflow-hidden truncate pr-2">
|
||||
<span v-text="note.Title" />
|
||||
<span class="hidden group-hover:block text-gray-400 dark:text-gray-500" v-text="'('+note.LinkedNotesCount+')'" />
|
||||
<span>
|
||||
</div>
|
||||
<span class="hidden group-hover:flex space-x-2 pr-2 whitespace-nowrap">
|
||||
<span title="list links" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 mt-1"
|
||||
@click.stop="$emit('finder-open', '/api/raw/links?color=true&filename=' + note.Filename)">
|
||||
<Icon name="mini-arrows-right-left" size="h-3 w-3" />
|
||||
</span>
|
||||
<span class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''"
|
||||
@click.stop="$emit('note-open', note.Filename, 1)">↗</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div v-if="note.LinkedNotesCount > 0" class="ml-[18px] border-dotted border-l border-gray-300 dark:border-gray-500">
|
||||
<div v-for="link in note.LinkedNotes" :key="'link-' + link.Filename" @click="$emit('note-open', link.Filename, 1)"
|
||||
class="group flex items-center justify-items-center justify-between pl-[18px] py-1 pr-2 truncate rounded-r-2xl
|
||||
text-gray-900 dark:text-gray-400 hover:bg-indigo-50 dark:hover:bg-gray-600">
|
||||
<div class="leading-6 overflow-hidden truncate" v-text="link.Title" :title="link.Title"></div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -mr-1 pr-1"
|
||||
@mouseenter="previewFilename=link.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
<li v-for="note in filteredNotes" :key="note.Filename"
|
||||
@click="$emit('note-open', note.Filename)"
|
||||
class="group flex justify-between items-center py-1 pl-4 pr-2 cursor-pointer rounded-r-2xl text-gray-900 hover:bg-indigo-50
|
||||
dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<div class="text-sm leading-6 overflow-hidden truncate" v-text="note.Title" :title="note.Title"></div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -mr-1 pr-1"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul v-else class="divide-y divide-gray-100 dark:divide-gray-600">
|
||||
<li v-for="note in filteredNotes" :key="note.Filename"
|
||||
@click="$emit('note-open', note.Filename)"
|
||||
class="group flex justify-between py-3 pl-4 pr-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<div class="truncate">
|
||||
<div class="text-sm leading-6 overflow-hidden truncate text-gray-900 dark:text-gray-300" v-text="note.Title" :title="note.Title"></div>
|
||||
<div class="flex space-x-1 overflow-hidden truncate text-xs text-gray-400 leading-6">
|
||||
<span v-if="$notesiumState.sidePanelSortNotes == 'ctime'" v-text="note.CtimeRelative" title="created" />
|
||||
<span v-else v-text="note.MtimeRelative" title="modified" />
|
||||
<div class="space-x-1 overflow-hidden truncate">
|
||||
<template v-for="label in note.LinkedLabels">
|
||||
<span>·</span>
|
||||
<span class="hover:text-gray-600 dark:hover:text-gray-200" v-text="label" @click.stop="query='label:'+label+' '"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden group-hover:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 -my-3 py-3 -mr-2 pr-2"
|
||||
@mouseenter="previewFilename=note.Filename" @mouseleave="previewFilename=''" v-text="'↗'">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="query" class="group m-4 cursor-pointer text-xs text-gray-500 dark:text-gray-400"
|
||||
@click="$emit('finder-open', '/api/raw/lines', query.replace(/label:/g, ''))">
|
||||
<div class="truncate">
|
||||
{{ filteredNotes.length }}/{{ notesLength }} matches for "<span class="italic text-gray-600 dark:text-gray-300" v-text="query"></span>"
|
||||
</div>
|
||||
<div class="group-hover:underline mt-1" >Full text search →</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Pane>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
29
internal/notesium/web/app/state.js
Normal file
29
internal/notesium/web/app/state.js
Normal file
@@ -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 };
|
||||
9
internal/notesium/web/app/tailwind.config.js
Normal file
9
internal/notesium/web/app/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["*.js", "!vendor.js", "!tailwind.config.js"],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
3
internal/notesium/web/app/tailwind.input.css
Normal file
3
internal/notesium/web/app/tailwind.input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
2
internal/notesium/web/graph/.gitignore
vendored
Normal file
2
internal/notesium/web/graph/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
tailwind.css
|
||||
d3.min.js
|
||||
100
internal/notesium/web/graph/README.md
Normal file
100
internal/notesium/web/graph/README.md
Normal file
@@ -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*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: cluster notes based on their titles instead of links*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: filter notes with emphasized matches. preview note content (dark mode)*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: zoomed out large note collection (dark mode)*
|
||||

|
||||
<br/>
|
||||
|
||||
## 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
|
||||
<title>Notesium Graph</title>
|
||||
- <!--
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { darkMode: 'class' }</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
|
||||
- -->
|
||||
- <link href="tailwind.css" rel="stylesheet">
|
||||
- <script src="d3.min.js"></script>
|
||||
<script src="forcegraph.js"></script>
|
||||
```
|
||||
|
||||
## 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 <silent> <Leader>ng :NotesiumGraph<CR>
|
||||
```
|
||||
|
||||
221
internal/notesium/web/graph/forcegraph.js
Normal file
221
internal/notesium/web/graph/forcegraph.js
Normal file
@@ -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, '<b><a href="' + href + '">$1</a></b>');
|
||||
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);
|
||||
}
|
||||
}
|
||||
393
internal/notesium/web/graph/index.html
Normal file
393
internal/notesium/web/graph/index.html
Normal file
@@ -0,0 +1,393 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Notesium Graph</title>
|
||||
<!--
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { darkMode: 'class' }</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
|
||||
-->
|
||||
<link href="tailwind.css" rel="stylesheet">
|
||||
<script src="d3.min.js"></script>
|
||||
<script src="forcegraph.js"></script>
|
||||
<style>
|
||||
#forcegraph { margin: auto; width: 97vw; height: 97vh; }
|
||||
#forcegraph .node { fill: #ff7f0e; cursor: pointer; }
|
||||
#forcegraph .node-oneword { fill: #1f77b4; }
|
||||
#forcegraph .node-ghost { fill: #a855f7; }
|
||||
#forcegraph .node-dangling { fill-opacity: 0.3; }
|
||||
#forcegraph .link { stroke-width: 0.5; }
|
||||
#forcegraph .label { fill: #475569; font-size: 4px; cursor: pointer; }
|
||||
#forcegraph .label:hover { text-decoration: underline; }
|
||||
.dark #forcegraph .label { fill: #94a3b8; }
|
||||
#forcegraph-filter-results li { margin-bottom: 3px; }
|
||||
#forcegraph-filter-results a:hover { text-decoration: underline; }
|
||||
#preview-panel-content a { color: #2563eb; }
|
||||
#preview-panel-content a:hover { text-decoration: underline; }
|
||||
#preview-panel-content .heading { color: #ff7f0e; font-weight: bold;}
|
||||
#preview-panel-content-body pre { background-color: #f8fafc; padding: 5px;}
|
||||
.dark #preview-panel-content a { color: #60a5fa; }
|
||||
.dark #preview-panel-content hr { border-top: 1px solid #475569; }
|
||||
.dark #preview-panel-content-body pre { background-color: #0f172a; padding: 5px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="h-screen bg-slate-50 dark:bg-slate-800 dark:text-white">
|
||||
|
||||
<div id="preview-panel" hidden class="relative z-10" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div class="w-screen max-w-xl">
|
||||
<div class="flex h-full flex-col overflow-y-scroll bg-white py-3 shadow-xl dark:text-slate-300 dark:shadow-slate-700 dark:bg-slate-800">
|
||||
<div class="absolute top-0 right-2 px-4 py-2 flex">
|
||||
<a id="preview-panel-editlink" href="#" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400" tabindex="-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</a>
|
||||
<div onclick="toggleDiv('#preview-panel')" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="preview-panel-content" class="relative flex-1 mt-4 px-4 sm:px-6 text-xs leading-normal">
|
||||
<pre id="preview-panel-content-body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="forcegraph-filter-results" class="absolute top-0 left-44 max-h-screen overflow-y-hidden pt-6 p-4 w-max text-gray-600 text-xs backdrop-blur-sm bg-slate-50/10 dark:text-slate-400 dark:bg-slate-800/10"></ul>
|
||||
|
||||
<div class="absolute w-44 space-y-2 text-xs text-gray-600 border border-gray-200 rounded-lg px-2 py-1.5 m-2 backdrop-blur-sm bg-white/30
|
||||
dark:text-slate-400 dark:border-slate-700 dark:bg-slate-900/50">
|
||||
<div class="flex items-center justify-between pt-1 pb-3">
|
||||
<input id="forcegraph-filter" placeholder="filter..." type="text" autocomplete="off" spellcheck="false"
|
||||
class="w-full px-2 py-1 border focus:outline-none rounded-md dark:bg-slate-900 dark:border-slate-700">
|
||||
<div class="flex h-5 items-center">
|
||||
<input id="forcegraph-filter-results-toggle" type="checkbox" checked class="h-4 w-4 ml-2 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="toggleDiv('#settings-display')" class="flex items-center justify-between py-1 cursor-pointer">
|
||||
<p class="text-sm text-gray-900 dark:text-slate-200">display</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul id="settings-display" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>dark mode</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input id="darkmode" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>show labels</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input id="forcegraph-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>auto-scale labels</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input id="forcegraph-scale-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>size nodes per links</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input id="forcegraph-dynamic-radius" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div onclick="toggleDiv('#settings-cluster')" class="flex items-center justify-between py-1 cursor-pointer">
|
||||
<p class="text-sm text-gray-900 dark:text-slate-200">cluster</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul id="settings-cluster" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>default</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input name="cluster" type="radio" onchange="clusterDefault()" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>infer from titles</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input name="cluster" type="radio" onchange="clusterTitles()" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>creation day</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM-DD')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>creation week</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY/WeekXX')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3">
|
||||
<span>creation month</span>
|
||||
<div class="flex h-5 items-center">
|
||||
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div onclick="toggleDiv('#settings-forces')" class="flex items-center justify-between py-1 cursor-pointer">
|
||||
<p class="text-sm text-gray-900 dark:text-slate-200">forces</p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul id="settings-forces" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
|
||||
<li class="py-3 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>repel force</span>
|
||||
<span id="forcegraph-force-strength-value">-30</span>
|
||||
</div>
|
||||
<input class="w-full" id="forcegraph-force-strength" type="range" value="-30" min="-100" max="0" step="1">
|
||||
</li>
|
||||
<li class="py-3 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>collide radius</span>
|
||||
<span id="forcegraph-collide-radius-value">1</span>
|
||||
</div>
|
||||
<input class="w-full" id="forcegraph-collide-radius" type="range" value="1" min="1" max="50" step="1">
|
||||
</li>
|
||||
<li class="py-3 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>collide strength</span>
|
||||
<span id="forcegraph-collide-strength-value">0.5</span>
|
||||
</div>
|
||||
<input class="w-full" id="forcegraph-collide-strength" type="range" value="0.5" min="0" max="1" step="0.05">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="forcegraph" class="text-gray-300/70 dark:text-slate-700"></div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
function sendHeartbeat() {
|
||||
fetch("/api/heartbeat")
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP status " + response.status);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Failed to send heartbeat: " + error.message);
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
}
|
||||
var heartbeatInterval = setInterval(sendHeartbeat, 5000);
|
||||
|
||||
var originalData = null;
|
||||
|
||||
function clusterDefault() {
|
||||
document.getElementById("forcegraph").innerHTML = "";
|
||||
initialize_forcegraph(originalData, '#forcegraph');
|
||||
}
|
||||
|
||||
function clusterTitles() {
|
||||
const data = {
|
||||
"href": originalData.href,
|
||||
"nodes": [ ...originalData.nodes ],
|
||||
"links": []
|
||||
};
|
||||
const clusters = new Set();
|
||||
data.nodes.forEach(node => {
|
||||
const match = node.title.match(/^(\w+):/);
|
||||
if (match) {
|
||||
clusters.add(match[1]);
|
||||
data.links.push({'source': node.id, 'target': match[1]});
|
||||
}
|
||||
});
|
||||
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
|
||||
|
||||
document.getElementById("forcegraph").innerHTML = "";
|
||||
initialize_forcegraph(data, '#forcegraph');
|
||||
}
|
||||
|
||||
function clusterDates(format="YYYY-MM-DD") {
|
||||
const data = {
|
||||
"href": originalData.href,
|
||||
"nodes": [ ...originalData.nodes ],
|
||||
"links": []
|
||||
};
|
||||
|
||||
const clusters = new Set();
|
||||
data.nodes.forEach(node => {
|
||||
let epoch = parseInt(node.id.slice(0, -3), 16);
|
||||
let dateTime = new Date(epoch * 1000);
|
||||
let year = dateTime.getFullYear();
|
||||
switch (format) {
|
||||
case 'YYYY-MM-DD':
|
||||
month = String(dateTime.getMonth() + 1).padStart(2, '0');
|
||||
day = String(dateTime.getDate()).padStart(2, '0');
|
||||
formattedDate = `${year}-${month}-${day}`;
|
||||
break;
|
||||
case 'YYYY-MM':
|
||||
month = String(dateTime.getMonth() + 1).padStart(2, '0');
|
||||
formattedDate = `${year}-${month}`;
|
||||
break;
|
||||
case 'YYYY/WeekXX':
|
||||
onejan = new Date(year, 0, 1);
|
||||
week = Math.ceil(((dateTime - onejan) / 86400000 + onejan.getDay() + 1) / 7);
|
||||
formattedDate = `${year}/Week${week}`;
|
||||
}
|
||||
clusters.add(formattedDate);
|
||||
data.links.push({'source': node.id, 'target': formattedDate});
|
||||
})
|
||||
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
|
||||
|
||||
document.getElementById("forcegraph").innerHTML = "";
|
||||
initialize_forcegraph(data, '#forcegraph');
|
||||
}
|
||||
|
||||
// toggle settings section
|
||||
function toggleDiv(settingsDiv) {
|
||||
d3.select(settingsDiv).attr("hidden", function() {
|
||||
return this.hasAttribute("hidden") ? null : true;
|
||||
});
|
||||
}
|
||||
|
||||
// toggle darkmode
|
||||
d3.select("#darkmode").on("change", function() {
|
||||
d3.select("body").classed("dark", this.checked);
|
||||
});
|
||||
|
||||
// toggle filtered results list
|
||||
d3.select("#forcegraph-filter-results-toggle").on("change", function() {
|
||||
d3.select("#forcegraph-filter-results").classed("hidden", !this.checked);
|
||||
});
|
||||
|
||||
// capture keypress events
|
||||
d3.select("body").on('keyup', function() {
|
||||
if (event.code === 'Slash' && document.activeElement.id != 'forcegraph-filter') {
|
||||
d3.select("#forcegraph-filter").node().focus();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.code === 'Escape' && document.activeElement.id === 'forcegraph-filter') {
|
||||
d3.select("#forcegraph-filter").node().value = '';
|
||||
d3.select("#forcegraph-filter").node().blur();
|
||||
d3.select("#forcegraph-filter").dispatch("input");
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// capture click events
|
||||
d3.select("body").on('click', function() {
|
||||
if (! event.target.attributes.href ) return;
|
||||
if (! event.target.attributes.href.value ) return;
|
||||
if (! event.target.attributes.href.value.endsWith('.md') ) return;
|
||||
if (! graphData.nodes.map(n => n.id).includes(event.target.attributes.href.value) ) return;
|
||||
event.preventDefault();
|
||||
getNote(event.target.attributes.href.value);
|
||||
});
|
||||
|
||||
// markdownify
|
||||
function markdownify(s) {
|
||||
const blocks = [];
|
||||
|
||||
s = s.replace(/(```)([^`]*?)(```)/gs, function(_, start, content, end) {
|
||||
var div = document.createElement("div");
|
||||
div.textContent = content;
|
||||
blocks.push('<pre>' + div.innerHTML + '</pre>');
|
||||
return '%%BLOCK' + (blocks.length - 1) + '%%';
|
||||
});
|
||||
|
||||
s = s
|
||||
.replace(/(^|\s)\[(.*?)\]\((http[s]?:\/\/.*?)\)/g, '$1<a href="$3" target="_blank">$2➜</a>')
|
||||
.replace(/(^|\s)\[(.*?)\]\((?!http[s]?:\/\/)(?!.*\.md$)(.*?)\)/g, '$1<a href="$3">$2</a>')
|
||||
.replace(/(^|\n)(#+\s.*?)(?=\n|$)/g, '$1<span class="heading">$2</span>')
|
||||
.replace(/\*\*(.*?)\*\*/gs, '<b>$1</b>')
|
||||
.replace(/`([^`]*)`/g, '<b><i>`$1`</i></b>')
|
||||
.trimEnd();
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
s = s.replace('%%BLOCK' + index + '%%', block);
|
||||
});
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// preview panel
|
||||
function getNote(id) {
|
||||
fetch("/api/notes/" + id)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById("preview-panel").hidden = false;
|
||||
document.getElementById("preview-panel-editlink").href = 'notesium://' + data.Path;
|
||||
document.getElementById("preview-panel-editlink").hidden = hideEditLink;
|
||||
document.getElementById("preview-panel-content-body").innerHTML = markdownify(data.Content);
|
||||
|
||||
var backlinks = ""
|
||||
if (data.IncomingLinks) {
|
||||
backlinks += "\n\n<hr/>\n\n## backlinks\n\n";
|
||||
|
||||
var linksWithTitles = data.IncomingLinks.map(link => {
|
||||
var node = graphData.nodes.find(node => node.id === link.Filename);
|
||||
var title = node ? node.title : link.Filename;
|
||||
return { link: link, title: title };
|
||||
});
|
||||
|
||||
linksWithTitles.sort((a, b) => a.title.localeCompare(b.title));
|
||||
linksWithTitles.forEach(item => {
|
||||
backlinks += `[${item.title}](${item.link.Filename})\n`;
|
||||
});
|
||||
|
||||
document.getElementById("preview-panel-content-body").innerHTML += markdownify(backlinks);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
const hideEditLink = new URLSearchParams(window.location.search).has('noxdg');
|
||||
|
||||
const graphData = {
|
||||
"nodes": [],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
fetch("/api/notes")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
for (const key in data) {
|
||||
const note = data[key];
|
||||
graphData.nodes.push({'id': note.Filename, 'title': note.Title});
|
||||
|
||||
if (note.OutgoingLinks) {
|
||||
note.OutgoingLinks.forEach(link => {
|
||||
graphData.links.push({'source': note.Filename, 'target': link.Filename});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dangling links
|
||||
const nodes = graphData.nodes.map(n => n.id);
|
||||
const targets = graphData.links.map(l => l.target);
|
||||
const dangling = targets.filter(id => !nodes.includes(id));
|
||||
dangling.forEach(id => graphData.nodes.push({'id': id, 'title': 'dangling link', 'type': 'ghost'}));
|
||||
|
||||
originalData = graphData;
|
||||
clusterDefault();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
60
internal/notesium/web/graph/make.sh
Executable file
60
internal/notesium/web/graph/make.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
fatal() { echo "Fatal: $*" 1>&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat<<EOF
|
||||
Usage: $0 COMMAND [OPTIONS]
|
||||
|
||||
Commands:
|
||||
all handle vendor, tailwind
|
||||
vendor download and verify vendor files
|
||||
tailwind [--watch] build tailwind.css
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
D3JS_URL="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"
|
||||
D3JS_HASH="d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c"
|
||||
|
||||
__vendor() {
|
||||
URL="$1"
|
||||
OUT="$(basename "$URL")"
|
||||
HASH="$2"
|
||||
if [ -e "$OUT" ]; then
|
||||
echo -n "$HASH $OUT" | sha256sum --strict --check -
|
||||
return 0
|
||||
fi
|
||||
curl -qs $URL -o $OUT.tmp
|
||||
echo -n "$HASH $OUT.tmp" | sha256sum --strict --check -
|
||||
mv $OUT.tmp $OUT
|
||||
}
|
||||
|
||||
_vendor() {
|
||||
command -v curl >/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 "$@"
|
||||
9
internal/notesium/web/graph/tailwind.config.js
Normal file
9
internal/notesium/web/graph/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["*.html"],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
3
internal/notesium/web/graph/tailwind.input.css
Normal file
3
internal/notesium/web/graph/tailwind.input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
internal/notesium/web/index.html
Normal file
10
internal/notesium/web/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Redirecting...</title>
|
||||
<meta http-equiv="refresh" content="0;url=/app/">
|
||||
</head>
|
||||
<body>
|
||||
<p>If you are not redirected, <a href="/app/">click here</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user