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:
2026-04-28 21:52:25 +02:00
parent aee9086633
commit 094250609c
63 changed files with 7391 additions and 0 deletions

6
.gitignore vendored
View File

@@ -9,3 +9,9 @@
!.wave/prompts/
wave.yaml
.env
# Build artifacts
/librenotes
/dist/
*.test
*.out

23
LICENSE Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
package main
import "git.librete.ch/public/librenotes/internal/notesium"
func main() {
notesium.Run()
}

18
go.mod Normal file
View 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
View 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
View 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, &notePost); 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, &notePatch); 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, &noteDelete); 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
View 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
}

View 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

View 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
}

View 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)
}
})
}
}

View 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
}

View 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()
}
}

View 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
}

View 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
}

View 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",
}
}

View 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
View 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
})
}

View 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
}

View 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
View File

@@ -0,0 +1,4 @@
.vendor/
vendor.js
vendor.css
tailwind.css

View 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
}

View 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
}

View 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;
}

View 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;
});
}

View 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
}

View 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
}

View 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);
}

View 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
}

View 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

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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>

View 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
}

View 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 "$@"

View 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
}

View 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
}

View 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}}&swarr;
</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}}&nearr;
</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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"> &rarr;</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
}

View 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 (&#96; ')</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
}

View 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
}

View 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
}

View 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
}

View 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 &amp; 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 &rarr;</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
}

View 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 };

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.js", "!vendor.js", "!tailwind.config.js"],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,2 @@
tailwind.css
d3.min.js

View 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*
![image: force graph cluster links](https://www.notesium.com/images/screenshot-1688650369.png)
<br/>
*Graph: cluster notes based on their titles instead of links*
![image: force graph cluster titles](https://www.notesium.com/images/screenshot-1687865971.png)
<br/>
*Graph: filter notes with emphasized matches. preview note content (dark mode)*
![image: force graph note preview](https://www.notesium.com/images/screenshot-1690971723.png)
<br/>
*Graph: zoomed out large note collection (dark mode)*
![image: force graph zoom](https://www.notesium.com/images/screenshot-1682941869.png)
<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>
```

View 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);
}
}

View 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>

View 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 "$@"

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.html"],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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>