├── tests ├── fixtures │ ├── 6421460b.md │ ├── 64217712.md │ ├── 642146c7.md │ ├── nonhex8.md │ ├── 642176a6.md │ ├── 64214930.md │ ├── 64218087.md │ ├── 64214a1d.md │ └── 64218088.md ├── wrap.awk ├── helpers.sh ├── extract.bats ├── weekly │ ├── weekly.bats │ ├── weekly.sh │ ├── weekly.txt │ ├── weekly.vim │ └── weekly.js ├── stats.bats ├── new.bats ├── orphan2.bats ├── orphan1.bats ├── cli.bats ├── runtime.bats ├── web.bats ├── links.bats ├── lines.bats ├── delete.bats ├── raw.bats └── write.bats ├── .gitignore ├── web ├── graph │ ├── .gitignore │ ├── tailwind.input.css │ ├── tailwind.config.js │ ├── make.sh │ └── README.md ├── app │ ├── .gitignore │ ├── tailwind.input.css │ ├── tailwind.config.js │ ├── state.js │ ├── favicon.svg │ ├── alert.js │ ├── dateutils.js │ ├── empty.js │ ├── settings.js │ ├── link-tree.js │ ├── pane.js │ ├── make.sh │ ├── settings-stats.js │ ├── periodic.js │ ├── settings-keybinds.js │ ├── confirm.js │ ├── preview.js │ ├── cm-vim.js │ ├── note-statusbar.js │ ├── ribbon.js │ ├── nav-tabs.js │ ├── finder.js │ ├── index.html │ ├── datepicker.js │ ├── note-sidebar.js │ ├── graph-panel.js │ ├── graph-d3.js │ └── cm-table.js └── index.html ├── go.mod ├── .github └── workflows │ ├── helpers │ ├── run-tests.sh │ ├── install-deps.sh │ ├── release-notes.sh │ └── build-bin.sh │ ├── pr.yml │ └── main.yml ├── heartbeat.go ├── LICENSE ├── finder.go ├── sort.go ├── completion.bash ├── contrib ├── xdg-urxvt-nvim.sh └── ctimehex.sh ├── filter.go ├── version_test.go ├── runtime.go ├── version.go ├── cache.go ├── highlight.go ├── filter_test.go ├── vim └── doc │ └── notesium.txt └── go.sum /tests/fixtures/6421460b.md: -------------------------------------------------------------------------------- 1 | # book 2 | -------------------------------------------------------------------------------- /tests/fixtures/64217712.md: -------------------------------------------------------------------------------- 1 | # empty note 2 | -------------------------------------------------------------------------------- /tests/fixtures/642146c7.md: -------------------------------------------------------------------------------- 1 | # physicist 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | vim/doc/tags 3 | notesium 4 | -------------------------------------------------------------------------------- /web/graph/.gitignore: -------------------------------------------------------------------------------- 1 | tailwind.css 2 | d3.min.js 3 | -------------------------------------------------------------------------------- /tests/fixtures/nonhex8.md: -------------------------------------------------------------------------------- 1 | # non-hex8 note filename 2 | 3 | -------------------------------------------------------------------------------- /web/app/.gitignore: -------------------------------------------------------------------------------- 1 | .vendor/ 2 | vendor.js 3 | vendor.css 4 | tailwind.css 5 | -------------------------------------------------------------------------------- /tests/wrap.awk: -------------------------------------------------------------------------------- 1 | { print "[", "\"${lines[" FNR-1 "]}\"", "==", "\"" $0 "\"", "]" } 2 | -------------------------------------------------------------------------------- /web/app/tailwind.input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /web/graph/tailwind.input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tests/fixtures/642176a6.md: -------------------------------------------------------------------------------- 1 | # lorem ipsum 2 | 3 | lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod 4 | tempor incididunt ut labore et dolore magna aliqua. 5 | -------------------------------------------------------------------------------- /tests/fixtures/64214930.md: -------------------------------------------------------------------------------- 1 | # quantum mechanics 2 | 3 | a fundamental theory in physics that provides a description of the 4 | physical properties of nature at the scale of atoms and subatomic 5 | particles. 6 | -------------------------------------------------------------------------------- /web/graph/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["*.html"], 4 | darkMode: 'class', 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } 10 | -------------------------------------------------------------------------------- /web/app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["*.js", "!vendor.js", "!tailwind.config.js"], 4 | darkMode: 'class', 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } 10 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirecting... 5 | 6 | 7 | 8 |

If you are not redirected, click here.

9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/helpers.sh: -------------------------------------------------------------------------------- 1 | flunk() { 2 | if [[ "$#" -eq 0 ]]; then cat -; else echo "$*"; fi 3 | return 1 4 | } 5 | 6 | assert_line() { 7 | local line 8 | for line in "${lines[@]}"; do [[ "$line" == "$1" ]] && return 0; done 9 | flunk "expected line \"$1\"" 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tests/fixtures/64218087.md: -------------------------------------------------------------------------------- 1 | # surely you're joking mr. feynman 2 | 3 | excellent [book](6421460b.md) by [richard feynman](64214a1d.md) and [ralph leighton](12345678.md). 4 | one of my favorite quotes: 5 | 6 | > you have no responsibility to live up to what other people think you 7 | > ought to accomplish. I have no responsibility to be like they expect 8 | > me to be. It's their mistake, not my failing. 9 | -------------------------------------------------------------------------------- /tests/fixtures/64214a1d.md: -------------------------------------------------------------------------------- 1 | # richard feynman 2 | 3 | richard phillips feynman was an american theoretical [physicist](642146c7.md), 4 | known for his work in the path integral formulation of 5 | [quantum mechanics](64214930.md), the theory of quantum electrodynamics, 6 | the physics of the superfluidity of supercooled liquid helium, as well 7 | as his work in particle physics for which he proposed the parton model. 8 | 9 | -------------------------------------------------------------------------------- /tests/fixtures/64218088.md: -------------------------------------------------------------------------------- 1 | # albert einstein 2 | 3 | albert einstein was a german-born theoretical [physicist](642146c7.md), 4 | widely acknowledged to be one of the greatest and most influential 5 | physicists of all time. einstein is best known for developing the 6 | [theory of relativity](xxxxxxxx.md), but he also made important contributions 7 | to the development of the theory of [quantum mechanics](64214930.md). 8 | 9 | -------------------------------------------------------------------------------- /tests/extract.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | setup_file() { 6 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 7 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 8 | } 9 | 10 | @test "extract: list files" { 11 | run notesium extract 12 | echo "$output" 13 | [ $status -eq 0 ] 14 | assert_line "completion.bash" 15 | assert_line "web/app/index.html" 16 | } 17 | 18 | @test "extract: print file content" { 19 | run notesium extract web/app/index.html 20 | echo "$output" 21 | [ $status -eq 0 ] 22 | [ "${lines[0]}" == "" ] 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alonswartz/notesium 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/charlievieth/fastwalk v1.0.9 // indirect 7 | github.com/gdamore/encoding v1.0.1 // indirect 8 | github.com/gdamore/tcell/v2 v2.8.1 // indirect 9 | github.com/junegunn/fzf v0.58.0 // indirect 10 | github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 // indirect 11 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 12 | github.com/mattn/go-isatty v0.0.20 // indirect 13 | github.com/mattn/go-runewidth v0.0.16 // indirect 14 | github.com/rivo/uniseg v0.4.7 // indirect 15 | golang.org/x/sys v0.29.0 // indirect 16 | golang.org/x/term v0.28.0 // indirect 17 | golang.org/x/text v0.21.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /tests/weekly/weekly.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | command -v nvim >/dev/null 5 | command -v node >/dev/null 6 | cd $(realpath $BATS_TEST_DIRNAME/) 7 | [ -e "weekly.txt" ] 8 | export WEEKLY_DATES="$(echo $(cat weekly.txt | cut -d' ' -f1))" 9 | } 10 | 11 | @test "weekly: bash" { 12 | run ./weekly.sh $WEEKLY_DATES 13 | echo "$output" 14 | [ $status -eq 0 ] 15 | diff <(echo "$output") weekly.txt 16 | } 17 | 18 | @test "weekly: vim" { 19 | run nvim -Es -c 'source ./weekly.vim' -c "RunWeeklyTest $WEEKLY_DATES" 20 | echo "$output" 21 | [ $status -eq 0 ] 22 | diff <(echo "$output") weekly.txt 23 | } 24 | 25 | @test "weekly: js" { 26 | run node ./weekly.js $WEEKLY_DATES 27 | echo "$output" 28 | [ $status -eq 0 ] 29 | diff <(echo "$output") weekly.txt 30 | } 31 | 32 | -------------------------------------------------------------------------------- /tests/stats.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 5 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 6 | } 7 | 8 | @test "stats: default" { 9 | run notesium stats 10 | echo "$output" 11 | [ $status -eq 0 ] 12 | [ "${lines[0]}" == "notes 8" ] 13 | [ "${lines[1]}" == "labels 2" ] 14 | [ "${lines[2]}" == "orphans 2" ] 15 | [ "${lines[3]}" == "links 7" ] 16 | [ "${lines[4]}" == "dangling 1" ] 17 | [ "${lines[5]}" == "lines 28" ] 18 | [ "${lines[6]}" == "words 213" ] 19 | [ "${lines[7]}" == "chars 1396" ] 20 | } 21 | 22 | @test "stats: table" { 23 | run notesium stats --table 24 | echo "$output" 25 | [ $status -eq 0 ] 26 | [ "${lines[0]}" == "notes 8" ] 27 | [ "${lines[7]}" == "chars 1396" ] 28 | } 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/helpers/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | SCRIPT_NAME="$(basename "$0")" 5 | fatal() { echo "[$SCRIPT_NAME] FATAL: $*" 1>&2; exit 1; } 6 | info() { echo "[$SCRIPT_NAME] INFO: $*"; } 7 | 8 | usage() { 9 | cat</dev/null || fatal "bats not found" 19 | 20 | [ -n "$GITHUB_WORKSPACE" ] || fatal "GITHUB_WORKSPACE not set" 21 | cd "$GITHUB_WORKSPACE" 22 | 23 | BINARY="$(realpath "$1")" 24 | [ -x "$BINARY" ] || fatal "does not exist or not executable: $BINARY" 25 | 26 | info "copying binary to $GITHUB_WORKSPACE/notesium" 27 | cp $BINARY $GITHUB_WORKSPACE/notesium 28 | 29 | info "running bats tests ..." 30 | bats tests/ 31 | } 32 | 33 | main "$@" 34 | -------------------------------------------------------------------------------- /web/app/state.js: -------------------------------------------------------------------------------- 1 | const { reactive, watch } = Vue; 2 | 3 | const defaultState = { 4 | showGraphPanel: false, 5 | showLabelsPanel: false, 6 | showNotesPanel: false, 7 | showNoteSidebar: true, 8 | notesPanelDarkMode: false, 9 | notesPanelCompact: false, 10 | notesPanelCompactLabels: true, 11 | sidePanelSortNotes: 'title', // title, links, mtime, ctime 12 | sidePanelSortLabels: 'title', // title, links 13 | editorFoldGutter: false, 14 | editorLineWrapping: false, 15 | editorConcealFormatting: true, 16 | editorVimMode: false, 17 | startOfWeek: 1, // 0 for Sunday, 1 for Monday, ... 18 | }; 19 | 20 | const savedState = localStorage.getItem('notesiumState'); 21 | const initialState = savedState ? JSON.parse(savedState) : defaultState; 22 | const notesiumState = reactive({ ...defaultState, ...initialState }); 23 | 24 | watch(notesiumState, (newState) => { 25 | Object.assign(newState, { ...defaultState, ...newState }); 26 | localStorage.setItem('notesiumState', JSON.stringify(newState)); 27 | }, { deep: true }); 28 | 29 | export { notesiumState }; 30 | -------------------------------------------------------------------------------- /web/app/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/weekly/weekly.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | weekly_test() { 4 | local date="$1" 5 | local epoch=$(date -d "$date" +%s) 6 | local day=$(date -d "@$epoch" +%u) 7 | 8 | local diff=$(( (day - $NOTESIUM_WEEKSTART + 7) % 7 )) 9 | local week_beg_epoch=$(( epoch - (diff * 86400) )) 10 | local week_beg_date=$(date -d "@$week_beg_epoch" +"%Y-%m-%dT00:00:01") 11 | local week_beg_str=$(date -d "@$week_beg_epoch" +"%a %b %d") 12 | local week_end_epoch=$(( week_beg_epoch + (6 * 86400) )) 13 | local week_end_str=$(date -d "@$week_end_epoch" +"%a %b %d") 14 | 15 | local year=$(date -d "@$week_beg_epoch" +"%G") 16 | local week_fmt=$([ "$NOTESIUM_WEEKSTART" -eq 0 ] && echo "%U" || echo "%V") 17 | local week_num=$(date -d "@$week_beg_epoch" +"${week_fmt}") 18 | 19 | local cdate=$(date -d "$week_beg_date" "+%Y-%m-%d") 20 | local title="# $year: Week$week_num ($week_beg_str - $week_end_str)" 21 | local date_input="${date}:$NOTESIUM_WEEKSTART" 22 | echo "$date_input $cdate $title" 23 | } 24 | 25 | for t in $@; do 26 | export NOTESIUM_WEEKSTART="${t##*:}" 27 | weekly_test "${t%%:*}" 28 | done 29 | 30 | -------------------------------------------------------------------------------- /heartbeat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | var ( 13 | mu sync.Mutex 14 | lastHeartbeat time.Time 15 | ) 16 | 17 | func updateHeartbeat() { 18 | mu.Lock() 19 | lastHeartbeat = time.Now() 20 | mu.Unlock() 21 | } 22 | 23 | func heartbeatH(next http.Handler) http.Handler { 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | updateHeartbeat() 26 | next.ServeHTTP(w, r) 27 | }) 28 | } 29 | 30 | func heartbeatF(next http.HandlerFunc) http.HandlerFunc { 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | updateHeartbeat() 33 | next(w, r) 34 | } 35 | } 36 | 37 | func checkHeartbeat(server *http.Server) { 38 | for { 39 | time.Sleep(5 * time.Second) 40 | mu.Lock() 41 | if time.Since(lastHeartbeat) > 10*time.Second { 42 | fmt.Println("No active client, stopping server.") 43 | mu.Unlock() 44 | if err := server.Shutdown(context.Background()); err != nil { 45 | log.Fatalf("Server shutdown failed: %+v", err) 46 | } 47 | break 48 | } 49 | mu.Unlock() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Alon Swartz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build and test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Install golang 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.24.2' 22 | 23 | - name: Run unit tests 24 | run: go test -v ./... 25 | 26 | - name: Install dependencies 27 | run: .github/workflows/helpers/install-deps.sh 28 | 29 | - name: Build web 30 | run: ./web/app/make.sh all 31 | 32 | - name: Build binaries 33 | run: .github/workflows/helpers/build-bin.sh build/ all 34 | 35 | - name: Run integration tests 36 | run: .github/workflows/helpers/run-tests.sh build/notesium-linux-amd64 37 | 38 | - name: Print version 39 | run: build/notesium-linux-amd64 version --verbose 40 | 41 | - name: Upload build artifacts 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: build 45 | path: build/ 46 | 47 | -------------------------------------------------------------------------------- /finder.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | fzf "github.com/junegunn/fzf/src" 7 | ) 8 | 9 | type channelWriter struct { 10 | ch chan string 11 | } 12 | 13 | func (cw *channelWriter) Write(p []byte) (n int, err error) { 14 | str := string(p) // Convert bytes to string 15 | cw.ch <- str // Send to channel 16 | return len(p), nil 17 | } 18 | 19 | func runFinder(inputChan chan string, opts []string) ([]string, int, error) { 20 | options, err := fzf.ParseOptions(false, opts) 21 | if err != nil { 22 | return nil, 2, fmt.Errorf("fzf error: %w", err) 23 | } 24 | 25 | outputChan := make(chan string) 26 | resultChan := make(chan struct { 27 | code int 28 | err error 29 | }, 1) 30 | 31 | options.Input = inputChan 32 | options.Output = outputChan 33 | 34 | go func() { 35 | code, runErr := fzf.Run(options) 36 | close(outputChan) 37 | 38 | resultChan <- struct { 39 | code int 40 | err error 41 | }{code, runErr} 42 | 43 | close(resultChan) 44 | }() 45 | 46 | var lines []string 47 | for line := range outputChan { 48 | lines = append(lines, line) 49 | } 50 | 51 | result := <-resultChan 52 | return lines, result.code, result.err 53 | } 54 | 55 | -------------------------------------------------------------------------------- /tests/weekly/weekly.txt: -------------------------------------------------------------------------------- 1 | 2023-12-30:0 2023-12-24 # 2023: Week52 (Sun Dec 24 - Sat Dec 30) 2 | 2023-12-31:0 2023-12-31 # 2023: Week53 (Sun Dec 31 - Sat Jan 06) 3 | 2024-01-02:0 2023-12-31 # 2023: Week53 (Sun Dec 31 - Sat Jan 06) 4 | 2024-01-07:0 2024-01-07 # 2024: Week01 (Sun Jan 07 - Sat Jan 13) 5 | 2024-06-22:0 2024-06-16 # 2024: Week24 (Sun Jun 16 - Sat Jun 22) 6 | 2024-06-23:0 2024-06-23 # 2024: Week25 (Sun Jun 23 - Sat Jun 29) 7 | 2024-06-26:0 2024-06-23 # 2024: Week25 (Sun Jun 23 - Sat Jun 29) 8 | 2024-06-29:0 2024-06-23 # 2024: Week25 (Sun Jun 23 - Sat Jun 29) 9 | 2024-06-30:0 2024-06-30 # 2024: Week26 (Sun Jun 30 - Sat Jul 06) 10 | 2023-12-30:1 2023-12-25 # 2023: Week52 (Mon Dec 25 - Sun Dec 31) 11 | 2023-12-31:1 2023-12-25 # 2023: Week52 (Mon Dec 25 - Sun Dec 31) 12 | 2024-01-02:1 2024-01-01 # 2024: Week01 (Mon Jan 01 - Sun Jan 07) 13 | 2024-01-07:1 2024-01-01 # 2024: Week01 (Mon Jan 01 - Sun Jan 07) 14 | 2024-06-22:1 2024-06-17 # 2024: Week25 (Mon Jun 17 - Sun Jun 23) 15 | 2024-06-23:1 2024-06-17 # 2024: Week25 (Mon Jun 17 - Sun Jun 23) 16 | 2024-06-26:1 2024-06-24 # 2024: Week26 (Mon Jun 24 - Sun Jun 30) 17 | 2024-06-29:1 2024-06-24 # 2024: Week26 (Mon Jun 24 - Sun Jun 30) 18 | 2024-06-30:1 2024-06-24 # 2024: Week26 (Mon Jun 24 - Sun Jun 30) 19 | -------------------------------------------------------------------------------- /web/app/alert.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |

11 |

12 |
13 |
14 | 18 |
19 |
20 |
21 |
22 | ` 23 | 24 | import Icon from './icon.js' 25 | export default { 26 | components: { Icon }, 27 | props: ['alert'], 28 | emits: ['alert-dismiss'], 29 | methods: { 30 | dismiss() { 31 | this.$emit('alert-dismiss', this.alert.id); 32 | }, 33 | }, 34 | mounted() { 35 | if (!this.alert.sticky) { 36 | setTimeout(() => { this.dismiss(); }, 2000); 37 | } 38 | }, 39 | template: t 40 | } 41 | -------------------------------------------------------------------------------- /sort.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | type SortByCtime []*Note 9 | type SortByMtime []*Note 10 | type SortByTitle []*Note 11 | 12 | func (n SortByCtime) Len() int { return len(n) } 13 | func (n SortByCtime) Less(i, j int) bool { return n[i].Ctime.After(n[j].Ctime) } 14 | func (n SortByCtime) Swap(i, j int) { n[i], n[j] = n[j], n[i] } 15 | 16 | func (n SortByMtime) Len() int { return len(n) } 17 | func (n SortByMtime) Less(i, j int) bool { return n[i].Mtime.After(n[j].Mtime) } 18 | func (n SortByMtime) Swap(i, j int) { n[i], n[j] = n[j], n[i] } 19 | 20 | func (n SortByTitle) Len() int { return len(n) } 21 | func (n SortByTitle) Less(i, j int) bool { return n[i].Title < n[j].Title } 22 | func (n SortByTitle) Swap(i, j int) { n[i], n[j] = n[j], n[i] } 23 | 24 | func getSortedNotes(sortBy string) []*Note { 25 | notes := make([]*Note, 0, len(noteCache)) 26 | 27 | for _, note := range noteCache { 28 | notes = append(notes, note) 29 | } 30 | 31 | switch sortBy { 32 | case "ctime": 33 | sort.Sort(SortByCtime(notes)) 34 | case "mtime": 35 | sort.Sort(SortByMtime(notes)) 36 | case "alpha": 37 | sort.Sort(SortByTitle(notes)) 38 | } 39 | 40 | return notes 41 | } 42 | 43 | func sortLinesByField(lines []string, separator string, fieldIndex int) { 44 | sort.Slice(lines, func(i, j int) bool { 45 | subI := strings.SplitN(lines[i], separator, fieldIndex+1)[fieldIndex] 46 | subJ := strings.SplitN(lines[j], separator, fieldIndex+1)[fieldIndex] 47 | return subI < subJ 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /web/app/dateutils.js: -------------------------------------------------------------------------------- 1 | export function formatDate(date, format) { 2 | const padZero = (num) => { return num.toString().padStart(2, '0'); } 3 | const replacements = { 4 | '%Y': date.getFullYear(), 5 | '%m': padZero(date.getMonth() + 1), 6 | '%d': padZero(date.getDate()), 7 | '%H': padZero(date.getHours()), 8 | '%M': padZero(date.getMinutes()), 9 | '%S': padZero(date.getSeconds()), 10 | '%A': date.toLocaleString('en-US', { weekday: 'long' }), 11 | '%a': date.toLocaleString('en-US', { weekday: 'short' }), 12 | '%b': date.toLocaleString('en-US', { month: 'short' }), 13 | '%u': date.getDay() === 0 ? 7 : date.getDay(), 14 | '%U': padZero(getSunWeekNum(date)), 15 | '%V': padZero(getISOWeekNum(date)), 16 | }; 17 | return format.replace(/%[a-zA-Z]/g, match => replacements[match]); 18 | } 19 | 20 | function getISOWeekNum(date) { 21 | const tempDate = new Date(date.getTime()); 22 | tempDate.setHours(0, 0, 0, 0); 23 | tempDate.setDate(tempDate.getDate() + 3 - (tempDate.getDay() + 6) % 7); 24 | const week1 = new Date(tempDate.getFullYear(), 0, 4); 25 | return 1 + Math.round(((tempDate.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7); 26 | } 27 | 28 | function getSunWeekNum(date) { 29 | const sunday = new Date(date); 30 | sunday.setDate(date.getDate() - date.getDay()); 31 | const firstSunday = new Date(sunday.getFullYear(), 0, 1); 32 | if (firstSunday.getDay() !== 0) firstSunday.setMonth(0, 1 + (7 - firstSunday.getDay()) % 7); 33 | return 1 + Math.floor((sunday - firstSunday) / 604800000); 34 | } 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/helpers/install-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | SCRIPT_NAME="$(basename "$0")" 5 | fatal() { echo "[$SCRIPT_NAME] FATAL: $*" 1>&2; exit 1; } 6 | info() { echo "[$SCRIPT_NAME] INFO: $*"; } 7 | 8 | usage() { 9 | cat</dev/null || fatal "git not found" 39 | command -v curl >/dev/null || fatal "curl not found" 40 | command -v sha256sum >/dev/null || fatal "sha256sum not found" 41 | 42 | [ -n "$GITHUB_WORKSPACE" ] || fatal "GITHUB_WORKSPACE not set" 43 | cd "$GITHUB_WORKSPACE" 44 | 45 | _install_bats_core 46 | _install_tailwindcss 47 | } 48 | 49 | main "$@" 50 | -------------------------------------------------------------------------------- /completion.bash: -------------------------------------------------------------------------------- 1 | __notesium_cmds() { 2 | notesium help 2>&1 | awk '/^ [a-z\-]/ {print $1}' 3 | } 4 | 5 | __notesium_opts() { 6 | notesium help 2>&1 | \ 7 | awk '/^ [a-z]/ {cmd=$1}; /^ --/ {print cmd, $1}' | \ 8 | awk -v cmd="^$1\ " '$0 ~ cmd {print $2}' | \ 9 | sed 's/--sort=WORD/--sort=ctime\n--sort=mtime\n--sort=alpha/' | \ 10 | sed 's/--prefix=WORD/--prefix=ctime\n--prefix=mtime\n--prefix=label/' 11 | } 12 | 13 | __notesium_complete() { 14 | local words 15 | case "${#COMP_WORDS[@]}" in 16 | 2) words="$(__notesium_cmds)";; 17 | *) words="$(__notesium_opts ${COMP_WORDS[1]})";; 18 | esac 19 | 20 | # handle options with equals. COMP_WORDBREAKS is global. 21 | _get_comp_words_by_ref -n = cur prev 22 | 23 | if [[ "${COMP_WORDS[1]}" == "finder" ]]; then 24 | if [[ "${prev}" == "--" ]]; then 25 | words="$(echo -e "list\nlinks\nlines")" 26 | else 27 | for ((i = 1; i < ${#COMP_WORDS[@]} - 1; i++)); do 28 | if [[ "${COMP_WORDS[i]}" == "--" ]]; then 29 | words="$(__notesium_opts "${COMP_WORDS[i+1]}")" 30 | break 31 | fi 32 | done 33 | fi 34 | fi 35 | 36 | case ${cur} in 37 | --prefix=*|--sort=*) 38 | prev="${cur%%=*}=" 39 | cur="${cur#*=}" 40 | words="$(echo "$words" | awk -F "=" -v p="^$prev" '$0 ~ p {print $2}')" 41 | COMPREPLY=($(compgen -W "$words" -- "${cur}")) 42 | return 0 43 | ;; 44 | esac 45 | 46 | COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}")) 47 | } 48 | 49 | complete -o default -F __notesium_complete notesium 50 | -------------------------------------------------------------------------------- /tests/weekly/weekly.vim: -------------------------------------------------------------------------------- 1 | " nvim -Es -c 'source weekly.vim' -c 'RunWeeklyTest 2023-12-30:0 ...' 2 | 3 | command! -bang -nargs=* WeeklyTest 4 | \ let s:date = empty() ? strftime('%Y-%m-%d') : | 5 | \ let s:output = system('notesium new --verbose --ctime='.s:date.'T00:00:01') | 6 | \ let s:epoch = str2nr(matchstr(s:output, 'epoch:\zs[^\n]*')) | 7 | \ let s:day = strftime('%u', s:epoch) | 8 | \ let s:startOfWeek = empty($NOTESIUM_WEEKSTART) ? 1 : $NOTESIUM_WEEKSTART | 9 | \ let s:diff = (s:day - s:startOfWeek + 7) % 7 | 10 | \ let s:weekBegEpoch = s:epoch - (s:diff * 86400) | 11 | \ let s:weekBegDate = strftime('%Y-%m-%d', s:weekBegEpoch) | 12 | \ let s:output = system('notesium new --verbose --ctime='.s:weekBegDate.'T00:00:01') | 13 | \ let s:filepath = matchstr(s:output, 'path:\zs[^\n]*') | 14 | \ let s:weekFmt = s:startOfWeek == 0 ? '%U' : '%V' | 15 | \ let s:yearWeekStr = strftime('%G: Week' . s:weekFmt, s:weekBegEpoch) | 16 | \ let s:weekBegStr = strftime('%a %b %d', s:weekBegEpoch) | 17 | \ let s:weekEndStr = strftime('%a %b %d', s:weekBegEpoch + (6 * 86400)) | 18 | \ let s:title = printf('# %s (%s - %s)', s:yearWeekStr, s:weekBegStr, s:weekEndStr) | 19 | \ let s:dateInput = s:date.':'.$NOTESIUM_WEEKSTART | 20 | \ echo s:dateInput s:weekBegDate s:title 21 | 22 | command! -bang -nargs=1 RunWeeklyTest 23 | \ let s:tests = split(, ' ') | 24 | \ redir => result | 25 | \ for s:test in s:tests | 26 | \ let s:args = split(s:test, ':') | 27 | \ let s:date = s:args[0] | 28 | \ let s:weekstart = s:args[1] | 29 | \ let $NOTESIUM_WEEKSTART = s:weekstart | 30 | \ silent! execute 'WeeklyTest ' . s:date | 31 | \ endfor | 32 | \ redir END | 33 | \ call writefile(split(result, "\n"), '/dev/stdout') | 34 | \ quit 35 | 36 | -------------------------------------------------------------------------------- /web/graph/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | fatal() { echo "Fatal: $*" 1>&2; exit 1; } 4 | 5 | usage() { 6 | cat</dev/null || fatal "curl not found" 36 | command -v sha256sum >/dev/null || fatal "sha256sum not found" 37 | __vendor "$D3JS_URL" "$D3JS_HASH" 38 | } 39 | 40 | _tailwind() { 41 | # tailwindcss v3.1.6 42 | OPTS="$@" 43 | command -v tailwindcss >/dev/null || fatal "tailwindcss not found" 44 | [ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found" 45 | [ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found" 46 | tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css 47 | } 48 | 49 | main() { 50 | cd $(dirname $(realpath $0)) 51 | case $1 in 52 | ""|-h|--help|help) usage;; 53 | all) _vendor; _tailwind;; 54 | vendor) _vendor;; 55 | tailwind) shift; _tailwind $@;; 56 | *) fatal "unrecognized command: $1";; 57 | esac 58 | } 59 | 60 | main "$@" 61 | -------------------------------------------------------------------------------- /contrib/xdg-urxvt-nvim.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | fatal() { echo "Fatal: $*" 1>&2; exit 1; } 5 | 6 | usage() { 7 | cat</dev/null & 29 | } 30 | 31 | _list_absolute() { 32 | [ -d "$1" ] || fatal "$1 does not exist or not directory" 33 | [ "$1" = "/" ] && fatal "directory not specified" 34 | export BAT_STYLE="plain" 35 | export NOTESIUM_DIR="$1" 36 | opts="-geometry 150x45+50+0" 37 | vimcmd="NotesiumList --prefix=label --sort=alpha --color" 38 | if [ "$THEME" = "light" ]; then 39 | export BAT_THEME="OneHalfLight" 40 | opts="$opts -name URxvtlight" 41 | fi 42 | title="${TITLE:-Notesium}" 43 | urxvt $opts -title "$title" -e nvim -c "$vimcmd" 2>/dev/null & 44 | } 45 | 46 | main() { 47 | case $1 in ''|-h|--help|help) usage;; esac 48 | command -v nvim >/dev/null || fatal "nvim not found" 49 | command -v urxvt >/dev/null || fatal "urxvt not found" 50 | 51 | case $1 in 52 | notesium:///*.md) _open_absolute "${1#notesium://}";; 53 | notesium:///*) _list_absolute "${1#notesium://}";; 54 | *) fatal "unrecognized uri scheme: $1";; 55 | esac 56 | } 57 | 58 | main "$@" 59 | -------------------------------------------------------------------------------- /web/app/empty.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |

notesium

4 |
5 | 15 |
16 |
17 | ` 18 | 19 | import Icon from './icon.js' 20 | export default { 21 | components: { Icon }, 22 | emits: ['note-new', 'note-daily', 'finder-open'], 23 | data() { 24 | return { 25 | entries: [ 26 | { 27 | title: 'New note', 28 | keybind: 'space n n', 29 | icon: 'outline-plus', 30 | emit: ['note-new'], 31 | }, 32 | { 33 | title: 'Daily note', 34 | keybind: 'space n d', 35 | icon: 'outline-calendar', 36 | emit: ['note-daily'], 37 | }, 38 | { 39 | title: 'List notes', 40 | keybind: 'space n l', 41 | icon: 'mini-bars-three-bottom-left', 42 | emit: ['finder-open', '/api/raw/list?color=true&prefix=label&sort=alpha'], 43 | }, 44 | { 45 | title: 'Search notes', 46 | keybind: 'space n s', 47 | icon: 'mini-magnifying-glass', 48 | emit: ['finder-open', '/api/raw/lines?color=true&prefix=title'], 49 | }, 50 | { 51 | title: 'Graph view', 52 | keybind: 'space n g', 53 | icon: 'graph', 54 | emit: ['graph-open'], 55 | }, 56 | ], 57 | } 58 | }, 59 | template: t 60 | } 61 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func tokenizeFilterQuery(query string) []string { 8 | tokens := []string{} 9 | var currentToken string 10 | var inQuotes bool 11 | var quoteChar rune 12 | 13 | for i := 0; i < len(query); i++ { 14 | c := rune(query[i]) 15 | 16 | // If we're not in quotes and encounter a quote, we enter 'quote mode' 17 | if !inQuotes && (c == '"' || c == '\'') { 18 | inQuotes = true 19 | quoteChar = c 20 | continue 21 | } 22 | 23 | // If we are in quotes and encounter the same quoteChar, we exit 'quote mode' 24 | if inQuotes && c == quoteChar { 25 | inQuotes = false 26 | continue 27 | } 28 | 29 | // If we're not in quotes and see a space, that's a token boundary 30 | if !inQuotes && c == ' ' { 31 | if currentToken != "" { 32 | tokens = append(tokens, currentToken) 33 | currentToken = "" 34 | } 35 | continue 36 | } 37 | 38 | // Otherwise, accumulate the character 39 | currentToken += string(c) 40 | } 41 | 42 | // Append the last token if non-empty 43 | if currentToken != "" { 44 | tokens = append(tokens, currentToken) 45 | } 46 | 47 | return tokens 48 | } 49 | 50 | func evaluateFilterQuery(query string, input string) (bool, error) { 51 | input = strings.ToLower(input) 52 | tokens := tokenizeFilterQuery(strings.ToLower(query)) 53 | matches := true 54 | 55 | for _, token := range tokens { 56 | if strings.Contains(token, "|") { 57 | // OR logic 58 | terms := strings.Split(token, "|") 59 | orMatch := false 60 | for _, term := range terms { 61 | if strings.Contains(input, term) { 62 | orMatch = true 63 | break 64 | } 65 | } 66 | matches = matches && orMatch 67 | } else if term, ok := strings.CutPrefix(token, "!"); ok { 68 | // NOT logic 69 | if strings.Contains(input, term) { 70 | matches = false 71 | break 72 | } 73 | } else { 74 | // AND logic (implicit) 75 | if !strings.Contains(input, token) { 76 | matches = false 77 | break 78 | } 79 | } 80 | } 81 | 82 | return matches, nil 83 | } 84 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - 'v*.*.*' 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | build: 15 | name: Build, test and draft release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Install golang 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '1.24.2' 27 | 28 | - name: Run unit tests 29 | run: go test -v ./... 30 | 31 | - name: Install dependencies 32 | run: .github/workflows/helpers/install-deps.sh 33 | 34 | - name: Build web 35 | run: ./web/app/make.sh all 36 | 37 | - name: Build binaries 38 | run: .github/workflows/helpers/build-bin.sh build/ all 39 | 40 | - name: Run integration tests 41 | run: .github/workflows/helpers/run-tests.sh build/notesium-linux-amd64 42 | 43 | - name: Print version 44 | run: build/notesium-linux-amd64 version --verbose 45 | 46 | - name: Generate release notes 47 | run: .github/workflows/helpers/release-notes.sh ${{ github.ref }} > build/release-notes.md 48 | 49 | - name: Upload build artifacts 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: build 53 | path: build/ 54 | 55 | - name: Create draft release 56 | if: startsWith(github.ref, 'refs/tags/') 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | draft: true 60 | token: ${{ secrets.GITHUB_TOKEN }} 61 | name: ${{ github.ref_name }} 62 | tag_name: ${{ github.ref_name }} 63 | fail_on_unmatched_files: true 64 | body_path: build/release-notes.md 65 | files: | 66 | build/checksums.txt 67 | build/notesium-linux-amd64 68 | build/notesium-linux-arm64 69 | build/notesium-darwin-amd64 70 | build/notesium-darwin-arm64 71 | build/notesium-windows-amd64.exe 72 | build/notesium-windows-arm64.exe 73 | 74 | -------------------------------------------------------------------------------- /.github/workflows/helpers/release-notes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | SCRIPT_NAME="$(basename "$0")" 5 | fatal() { echo "[$SCRIPT_NAME] FATAL: $*" 1>&2; exit 1; } 6 | info() { echo "[$SCRIPT_NAME] INFO: $*"; } 7 | 8 | usage() { 9 | cat<1{exit}' CHANGELOG.md 32 | else 33 | # fallback: in case changelog has not been updated 34 | tag1="$(git tag --list | sort -V | tail -n 1)" 35 | tag2="$(git tag --list | sort -V | tail -n 2 | head -n 1)" 36 | if [ "$tag" = "$tag1" ]; then 37 | _print_commits $tag2 $tag 38 | else 39 | # this should never happen on a tagged release 40 | echo "## refs/tags/${tag} is not latest tag: ${tag1}\n" 41 | _print_commits $tag1 HEAD 42 | fi 43 | fi 44 | } 45 | 46 | _pre_release() { 47 | branch="$1" 48 | GIT_VERSION="$(git describe --tags | sed 's/^v//; s/-/+/')" 49 | echo "## Pre-release ${GIT_VERSION} (refs/heads/${branch})\n" 50 | tag1="$(git tag --list | sort -V | tail -n 1)" 51 | _print_commits $tag1 HEAD 52 | } 53 | 54 | main() { 55 | case $1 in ''|-h|--help|help) usage;; esac 56 | command -v git >/dev/null || fatal "git not found" 57 | 58 | [ -n "$GITHUB_WORKSPACE" ] || fatal "GITHUB_WORKSPACE not set" 59 | cd "$GITHUB_WORKSPACE" 60 | 61 | case "$1" in 62 | refs/tags/*) _tagged_release $(basename "$1");; 63 | refs/heads/*) _pre_release $(basename "$1");; 64 | *) fatal "unsupported GIT_REF: $1";; 65 | esac 66 | } 67 | 68 | main "$@" 69 | -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetVersion(t *testing.T) { 8 | tests := []struct { 9 | input string 10 | expected string 11 | }{ 12 | {"v0.1.2-0-g1234567", "0.1.2"}, 13 | {"v0.1.2-2-g1234567", "0.1.2+2"}, 14 | {"v0.1.2-0-g1234567-dirty", "0.1.2+0-dirty"}, 15 | {"v0.1.2-2-g1234567-dirty", "0.1.2+2-dirty"}, 16 | {"v0.2.0-beta-0-g1234567", "0.2.0-beta"}, 17 | {"v0.2.0-beta-0-g1234567-dirty", "0.2.0-beta+0-dirty"}, 18 | {"v0.2.0-beta-2-g1234567", "0.2.0-beta+2"}, 19 | {"v0.2.0-rc.2-0-g1234567", "0.2.0-rc.2"}, 20 | {"v0.2.0-rc.2-0-g1234567-dirty", "0.2.0-rc.2+0-dirty"}, 21 | {"v0.2.0-rc.2-2-g1234567", "0.2.0-rc.2+2"}, 22 | {"v0.1.2-0-g1234567-foo", "0.0.0-dev"}, 23 | {"v0.1.2-foo-g1234567", "0.0.0-dev"}, 24 | {"0.1.2-0-g1234567", "0.0.0-dev"}, 25 | {"unset", "0.0.0-dev"}, 26 | {"foo", "0.0.0-dev"}, 27 | {"", "0.0.0-dev"}, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.input, func(t *testing.T) { 32 | result := getVersion(tt.input) 33 | if result != tt.expected { 34 | t.Errorf("getVersion(%s), want %s", result, tt.expected) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func TestCompareVersions(t *testing.T) { 41 | // -1 if v1 < v2, 1 if v1 > v2, and 0 if they are equal. 42 | tests := []struct { 43 | v1 string 44 | v2 string 45 | expected int 46 | }{ 47 | {"", "", 0}, 48 | {"1.2.3", "", 1}, 49 | {"0.0.0", "", 0}, 50 | {"0.0.0-dev", "", -1}, 51 | {"0.0.0-dev", "1.2.3", -1}, 52 | {"1.2.3", "1.2.2", 1}, 53 | {"1.2.3", "1.2.3", 0}, 54 | {"1.2.3", "1.2.4", -1}, 55 | {"1.2.3+2", "1.2.2", 1}, 56 | {"1.2.3+2", "1.2.3", 0}, 57 | {"1.2.3+2", "1.2.4", -1}, 58 | {"1.2.3-beta", "1.2.1", 1}, 59 | {"1.2.3-beta", "1.2.2", 0}, 60 | {"1.2.3-beta", "1.2.3", -1}, 61 | {"1.2.3-beta", "1.2.4", -1}, 62 | {"1.2.0-beta", "1.2.0", -1}, 63 | {"1.2.0-beta", "1.2.4-beta", 1}, 64 | {"1.2.1-beta.2", "1.2.1", -1}, 65 | } 66 | 67 | for _, tt := range tests { 68 | t.Run(tt.v1+"_"+tt.v2, func(t *testing.T) { 69 | result := compareVersions(tt.v1, tt.v2) 70 | if result != tt.expected { 71 | t.Errorf("compareVersions(%s, %s) = %d; want %d", tt.v1, tt.v2, result, tt.expected) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /web/app/settings.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 29 | ` 30 | 31 | import KeyBinds from './settings-keybinds.js' 32 | import Editor from './settings-editor.js' 33 | import About from './settings-about.js' 34 | import Stats from './settings-stats.js' 35 | export default { 36 | components: { KeyBinds, Editor, About, Stats }, 37 | props: ['versionCheck'], 38 | emits: ['settings-close', 'version-check', 'finder-open'], 39 | data() { 40 | return { 41 | active: 'keybinds', 42 | sections: [ 43 | ['keybinds', 'Key Bindings'], 44 | ['editor', 'Editor'], 45 | ['stats', 'Statistics'], 46 | ['about', 'About'], 47 | ], 48 | } 49 | }, 50 | created() { 51 | if (this.versionCheck.comparison == '-1') this.active = 'about'; 52 | }, 53 | template: t 54 | } 55 | -------------------------------------------------------------------------------- /web/app/link-tree.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 |
5 | 6 | 9 |
10 | 13 |
14 | 15 |
16 | 19 |
20 |
21 | ` 22 | 23 | export default { 24 | name: 'LinkTree', 25 | props: ['filename', 'title', 'linenum', 'direction'], 26 | emits: ['note-open'], 27 | data() { 28 | return { 29 | expanded: false, 30 | children: [], 31 | } 32 | }, 33 | methods: { 34 | toggle() { 35 | this.expanded = !this.expanded; 36 | if (this.expanded && this.children.length === 0) { 37 | this.fetchChildren(); 38 | } 39 | }, 40 | fetchChildren() { 41 | fetch('/api/raw/links?color=true&filename=' + this.filename) 42 | .then(response => response.text()) 43 | .then(text => { 44 | const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/ 45 | this.children = text.trim().split('\n').map(line => { 46 | const matches = PATTERN.exec(line); 47 | if (!matches) return null; 48 | const Filename = matches[1]; 49 | const Linenum = parseInt(matches[2], 10); 50 | const Colored = matches[3] || ''; 51 | const Content = matches[4]; 52 | return { title: Content, filename: Filename, linenum: (Colored == 'outgoing') ? 1 : Linenum, direction: Colored }; 53 | }).filter(Boolean); 54 | }); 55 | }, 56 | }, 57 | template: t 58 | } 59 | -------------------------------------------------------------------------------- /tests/new.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | export TZ="UTC" 5 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 6 | run mkdir /tmp/notesium-test-corpus 7 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 8 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 9 | } 10 | 11 | teardown_file() { 12 | run rmdir /tmp/notesium-test-corpus 13 | } 14 | 15 | @test "new: dirname equal to NOTESIUM_DIR realpath" { 16 | run notesium new 17 | echo "$output" 18 | [ $status -eq 0 ] 19 | [ "$(dirname $output)" == "$(realpath /tmp/notesium-test-corpus)" ] 20 | } 21 | 22 | @test "new: basename is 8 chars plus .md extension" { 23 | run notesium new 24 | echo "$output" 25 | [ $status -eq 0 ] 26 | [ "$(basename $output | tr -d '\n' | wc -c)" == "11" ] 27 | [ "$(basename --suffix=.md $output | tr -d '\n' | wc -c)" == "8" ] 28 | } 29 | 30 | @test "new: basename is hex for now epoch (within 10s range)" { 31 | run notesium new 32 | echo "$output" 33 | [ $status -eq 0 ] 34 | epoch="$(printf '%d' 0x$(basename --suffix=.md $output))" 35 | [ "$epoch" -gt "$(date -d "-5 seconds" +%s)" ] 36 | [ "$epoch" -lt "$(date -d "+5 seconds" +%s)" ] 37 | } 38 | 39 | @test "new: basename is hex for specified ctime epoch" { 40 | run notesium new --ctime=2023-01-16T05:05:00 41 | echo "$output" 42 | [ $status -eq 0 ] 43 | epoch="$(printf '%d' 0x$(basename --suffix=.md $output))" 44 | [ "$(date --date=@${epoch} '+%FT%T')" == "2023-01-16T05:05:00" ] 45 | } 46 | 47 | @test "new: verbose output for specified ctime" { 48 | run notesium new --ctime=2023-01-16T05:05:00 --verbose 49 | echo "$output" 50 | [ "${#lines[@]}" -eq 5 ] 51 | [ "${lines[0]}" == "path:$(realpath /tmp/notesium-test-corpus/63c4dafc.md)" ] 52 | [ "${lines[1]}" == "filename:63c4dafc.md" ] 53 | [ "${lines[2]}" == "epoch:1673845500" ] 54 | [ "${lines[3]}" == "ctime:2023-01-16T05:05:00+00:00" ] 55 | [ "${lines[4]}" == "exists:false" ] 56 | } 57 | 58 | @test "new: verbose output for specified ctime exists" { 59 | touch /tmp/notesium-test-corpus/63c4dafc.md 60 | run notesium new --ctime=2023-01-16T05:05:00 --verbose 61 | echo "$output" 62 | [ "${lines[4]}" == "exists:true" ] 63 | rm /tmp/notesium-test-corpus/63c4dafc.md 64 | } 65 | 66 | @test "new: invalid specified ctime" { 67 | run notesium new --ctime=2023-01-16 68 | echo "$output" 69 | [ $status -eq 1 ] 70 | } 71 | -------------------------------------------------------------------------------- /tests/orphan2.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 5 | run mkdir /tmp/notesium-test-corpus 6 | run cp $BATS_TEST_DIRNAME/fixtures/6421460b.md /tmp/notesium-test-corpus/ 7 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 8 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 9 | } 10 | 11 | teardown_file() { 12 | run rm /tmp/notesium-test-corpus/6421460b.md 13 | run rmdir /tmp/notesium-test-corpus 14 | } 15 | 16 | @test "orphan2: list" { 17 | run notesium list 18 | echo "$output" 19 | [ $status -eq 0 ] 20 | [ "${lines[0]}" == "6421460b.md:1: book" ] 21 | } 22 | 23 | @test "orphan2: list labels" { 24 | run notesium list --labels 25 | echo "$output" 26 | [ $status -eq 0 ] 27 | [ "${lines[0]}" == "6421460b.md:1: book" ] 28 | } 29 | 30 | @test "orphan2: list prefix label" { 31 | run notesium list --prefix=label 32 | echo "$output" 33 | [ $status -eq 0 ] 34 | [ "${lines[0]}" == "6421460b.md:1: book" ] 35 | } 36 | 37 | @test "orphan2: list orphans" { 38 | run notesium list --orphans 39 | echo "$output" 40 | [ $status -eq 0 ] 41 | [ "${lines[0]}" == "6421460b.md:1: book" ] 42 | } 43 | 44 | @test "orphan2: links" { 45 | run notesium links 46 | echo "$output" 47 | [ $status -eq 0 ] 48 | [ "${lines[0]}" == '' ] 49 | } 50 | 51 | @test "orphan2: links file" { 52 | run notesium links 6421460b.md 53 | echo "$output" 54 | [ $status -eq 0 ] 55 | [ "${lines[0]}" == '' ] 56 | } 57 | 58 | @test "orphan2: links outgoing" { 59 | run notesium links --outgoing 6421460b.md 60 | echo "$output" 61 | [ $status -eq 0 ] 62 | [ "${lines[0]}" == '' ] 63 | } 64 | 65 | @test "orphan2: links incoming" { 66 | run notesium links --incoming 6421460b.md 67 | echo "$output" 68 | [ $status -eq 0 ] 69 | [ "${lines[0]}" == '' ] 70 | } 71 | 72 | @test "orphan2: links dangling" { 73 | run notesium links --dangling 74 | echo "$output" 75 | [ $status -eq 0 ] 76 | [ "${lines[0]}" == '' ] 77 | } 78 | 79 | @test "orphan2: lines" { 80 | run notesium lines 81 | echo "$output" 82 | [ $status -eq 0 ] 83 | [ "${lines[0]}" == "6421460b.md:1: # book" ] 84 | } 85 | 86 | @test "orphan2: lines prefix title" { 87 | run notesium lines --prefix=title 88 | echo "$output" 89 | [ $status -eq 0 ] 90 | [ "${lines[0]}" == "6421460b.md:1: book # book" ] 91 | } 92 | -------------------------------------------------------------------------------- /tests/orphan1.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 5 | run mkdir /tmp/notesium-test-corpus 6 | run cp $BATS_TEST_DIRNAME/fixtures/64217712.md /tmp/notesium-test-corpus/ 7 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 8 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 9 | } 10 | 11 | teardown_file() { 12 | run rm /tmp/notesium-test-corpus/64217712.md 13 | run rmdir /tmp/notesium-test-corpus 14 | } 15 | 16 | @test "orphan1: list" { 17 | run notesium list 18 | echo "$output" 19 | [ $status -eq 0 ] 20 | [ "${lines[0]}" == "64217712.md:1: empty note" ] 21 | } 22 | 23 | @test "orphan1: list labels" { 24 | run notesium list --labels 25 | echo "$output" 26 | [ $status -eq 0 ] 27 | [ "${lines[0]}" == '' ] 28 | } 29 | 30 | @test "orphan1: list prefix label" { 31 | run notesium list --prefix=label 32 | echo "$output" 33 | [ $status -eq 0 ] 34 | [ "${lines[0]}" == "64217712.md:1: empty note" ] 35 | } 36 | 37 | @test "orphan1: list orphans" { 38 | run notesium list --orphans 39 | echo "$output" 40 | [ $status -eq 0 ] 41 | [ "${lines[0]}" == '64217712.md:1: empty note' ] 42 | } 43 | 44 | @test "orphan1: links" { 45 | run notesium links 46 | echo "$output" 47 | [ $status -eq 0 ] 48 | [ "${lines[0]}" == '' ] 49 | } 50 | 51 | @test "orphan1: links file" { 52 | run notesium links 64217712.md 53 | echo "$output" 54 | [ $status -eq 0 ] 55 | [ "${lines[0]}" == '' ] 56 | } 57 | 58 | @test "orphan1: links outgoing" { 59 | run notesium links --outgoing 64217712.md 60 | echo "$output" 61 | [ $status -eq 0 ] 62 | [ "${lines[0]}" == '' ] 63 | } 64 | 65 | @test "orphan1: links incoming" { 66 | run notesium links --incoming 64217712.md 67 | echo "$output" 68 | [ $status -eq 0 ] 69 | [ "${lines[0]}" == '' ] 70 | } 71 | 72 | @test "orphan1: links dangling" { 73 | run notesium links --dangling 74 | echo "$output" 75 | [ $status -eq 0 ] 76 | [ "${lines[0]}" == '' ] 77 | } 78 | 79 | @test "orphan1: lines" { 80 | run notesium lines 81 | echo "$output" 82 | [ $status -eq 0 ] 83 | [ "${lines[0]}" == '64217712.md:1: # empty note' ] 84 | } 85 | 86 | @test "orphan1: lines prefix title" { 87 | run notesium lines --prefix=title 88 | echo "$output" 89 | [ $status -eq 0 ] 90 | [ "${lines[0]}" == '64217712.md:1: empty note # empty note' ] 91 | } 92 | -------------------------------------------------------------------------------- /tests/cli.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup_file() { 4 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 5 | run mkdir /tmp/notesium-test-corpus 6 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 7 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 8 | } 9 | 10 | teardown_file() { 11 | run rmdir /tmp/notesium-test-corpus 12 | } 13 | 14 | @test "cli: print usage if no arguments specified" { 15 | run notesium 16 | echo "$output" 17 | [ $status -eq 1 ] 18 | [ "${lines[0]}" == 'Usage: notesium COMMAND [OPTIONS]' ] 19 | } 20 | 21 | @test "cli: print usage if -h --help help" { 22 | run notesium -h 23 | echo "$output" 24 | [ $status -eq 1 ] 25 | [ "${lines[0]}" == 'Usage: notesium COMMAND [OPTIONS]' ] 26 | 27 | run notesium --help 28 | echo "$output" 29 | [ $status -eq 1 ] 30 | [ "${lines[0]}" == 'Usage: notesium COMMAND [OPTIONS]' ] 31 | 32 | run notesium help 33 | echo "$output" 34 | [ $status -eq 1 ] 35 | [ "${lines[0]}" == 'Usage: notesium COMMAND [OPTIONS]' ] 36 | } 37 | 38 | @test "cli: unrecognized command fatal error" { 39 | run notesium foo 40 | echo "$output" 41 | [ $status -eq 1 ] 42 | [[ "${lines[0]}" =~ 'unrecognized command: foo' ]] 43 | } 44 | 45 | @test "cli: unrecognized command option fatal error" { 46 | run notesium new --foo 47 | echo "$output" 48 | [ $status -eq 1 ] 49 | [[ "${lines[0]}" =~ 'unrecognized option: --foo' ]] 50 | } 51 | 52 | @test "cli: unrecognized option fatal error" { 53 | run notesium --foo 54 | echo "$output" 55 | [ $status -eq 1 ] 56 | [[ "${lines[0]}" =~ 'unrecognized option: --foo' ]] 57 | } 58 | 59 | @test "cli: home error if NOTESIUM_DIR does not exist" { 60 | export NOTESIUM_DIR="/tmp/notesium-test-foo" 61 | run notesium home 62 | echo "$output" 63 | [ $status -eq 1 ] 64 | [[ "${lines[0]}" =~ "NOTESIUM_DIR does not exist: $NOTESIUM_DIR" ]] 65 | } 66 | 67 | @test "cli: home prints default NOTESIUM_DIR if not set" { 68 | [ -e "$HOME/notes" ] || skip "$HOME/notes does not exist" 69 | unset NOTESIUM_DIR 70 | run notesium home 71 | echo "$output" 72 | [ $status -eq 0 ] 73 | [ "${lines[0]}" == "$(realpath $HOME/notes)" ] 74 | } 75 | 76 | @test "cli: home prints NOTESIUM_DIR upon successful verification" { 77 | run notesium home 78 | echo "$output" 79 | [ $status -eq 0 ] 80 | [ "${lines[0]}" == "$(realpath /tmp/notesium-test-corpus)" ] 81 | } 82 | 83 | -------------------------------------------------------------------------------- /runtime.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | type WebInfo struct { 9 | Webroot string `json:"webroot"` 10 | Writable bool `json:"writable"` 11 | StopOnIdle bool `json:"stop-on-idle"` 12 | VersionCheck bool `json:"daily-version-check"` 13 | } 14 | 15 | type BuildInfo struct { 16 | GitVersion string `json:"gitversion"` 17 | Buildtime string `json:"buildtime"` 18 | GoVersion string `json:"goversion"` 19 | LatestReleaseURL string `json:"latest-release-url"` 20 | } 21 | 22 | type MemoryInfo struct { 23 | MemoryAlloc string `json:"alloc"` 24 | MemoryTotalAlloc string `json:"total-alloc"` 25 | MemorySys string `json:"sys"` 26 | MemoryLookups uint64 `json:"lookups"` 27 | MemoryMallocs uint64 `json:"mallocs"` 28 | MemoryFrees uint64 `json:"frees"` 29 | } 30 | 31 | type RuntimeResponse struct { 32 | Home string `json:"home"` 33 | Version string `json:"version"` 34 | Platform string `json:"platform"` 35 | Web WebInfo `json:"web"` 36 | Build BuildInfo `json:"build"` 37 | Memory MemoryInfo `json:"memory"` 38 | } 39 | 40 | func GetRuntimeInfo(dir string, webOpts webOptions) RuntimeResponse { 41 | var memStats runtime.MemStats 42 | runtime.ReadMemStats(&memStats) 43 | 44 | return RuntimeResponse{ 45 | Home: dir, 46 | Version: getVersion(gitversion), 47 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 48 | Web: WebInfo{ 49 | Webroot: webOpts.webroot, 50 | Writable: !webOpts.readOnly, 51 | StopOnIdle: webOpts.heartbeat, 52 | VersionCheck: webOpts.check, 53 | }, 54 | Build: BuildInfo{ 55 | GitVersion: gitversion, 56 | Buildtime: buildtime, 57 | GoVersion: runtime.Version(), 58 | LatestReleaseURL: latestReleaseURL, 59 | }, 60 | Memory: MemoryInfo{ 61 | MemoryAlloc: bytesToHumanReadable(memStats.Alloc), 62 | MemoryTotalAlloc: bytesToHumanReadable(memStats.TotalAlloc), 63 | MemorySys: bytesToHumanReadable(memStats.Sys), 64 | MemoryLookups: memStats.Lookups, 65 | MemoryMallocs: memStats.Mallocs, 66 | MemoryFrees: memStats.Frees, 67 | }, 68 | } 69 | } 70 | 71 | func bytesToHumanReadable(bytes uint64) string { 72 | const unit = 1024 73 | if bytes < unit { 74 | return fmt.Sprintf("%d B", bytes) 75 | } 76 | div, exp := int64(unit), 0 77 | for n := bytes / unit; n >= unit; n /= unit { 78 | div *= unit 79 | exp++ 80 | } 81 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 82 | } 83 | -------------------------------------------------------------------------------- /web/app/pane.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 | 5 |
6 |
7 |
10 |
11 |
12 |
13 | ` 14 | 15 | export default { 16 | props: { 17 | name: { type: String }, 18 | defaultWidth: { type: Number, default: 200 }, 19 | minWidth: { type: Number, default: 100 }, 20 | direction: { type: String, default: "right" }, 21 | }, 22 | data() { 23 | return { 24 | paneWidth: null, 25 | maxWidth: null, 26 | startResizeClientX: null, 27 | startResizePaneWidth: null, 28 | resizing: false, 29 | } 30 | }, 31 | methods: { 32 | startResize(event) { 33 | this.startResizeClientX = event.clientX; 34 | this.startResizePaneWidth = this.paneWidth; 35 | this.maxWidth = this.$el.parentElement.offsetWidth - 50; 36 | this.resizing = true; 37 | event.preventDefault(); 38 | document.addEventListener('mousemove', this.doResize); 39 | document.addEventListener('mouseup', this.stopResize); 40 | }, 41 | doResize(event) { 42 | let draggedDistance = event.clientX - this.startResizeClientX; 43 | if (this.direction === 'left') draggedDistance = -draggedDistance; 44 | const newWidth = this.startResizePaneWidth + draggedDistance; 45 | if (newWidth <= this.maxWidth && newWidth >= this.minWidth ) this.paneWidth = newWidth; 46 | }, 47 | stopResize() { 48 | this.resizing = false; 49 | this.savePreferredWidth(); 50 | document.removeEventListener('mousemove', this.doResize); 51 | document.removeEventListener('mouseup', this.stopResize); 52 | }, 53 | setDefaultWidth() { 54 | this.paneWidth = this.defaultWidth; 55 | this.savePreferredWidth() 56 | }, 57 | savePreferredWidth() { 58 | const key = `${this.name}Width`; 59 | this.$notesiumState[key] = this.paneWidth; 60 | }, 61 | loadPreferredWidth() { 62 | const key = `${this.name}Width`; 63 | this.paneWidth = parseInt(this.$notesiumState[key], 10) || this.defaultWidth; 64 | }, 65 | }, 66 | mounted() { 67 | this.loadPreferredWidth(); 68 | }, 69 | template: t 70 | } 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/helpers/build-bin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | SCRIPT_NAME="$(basename "$0")" 5 | fatal() { echo "[$SCRIPT_NAME] FATAL: $*" 1>&2; exit 1; } 6 | info() { echo "[$SCRIPT_NAME] INFO: $*"; } 7 | 8 | usage() { 9 | cat</dev/null || fatal "go not found" 37 | command -v git >/dev/null || fatal "git not found" 38 | command -v sha256sum >/dev/null || fatal "sha256sum not found" 39 | 40 | [ -n "$GITHUB_WORKSPACE" ] || fatal "GITHUB_WORKSPACE not set" 41 | cd "$GITHUB_WORKSPACE" 42 | 43 | OUTDIR="$(realpath "$1")" 44 | [ -d "$OUTDIR" ] || mkdir -p "$OUTDIR" 45 | 46 | GIT_VERSION="$(git describe --tags --long --always --dirty)" 47 | [ -n "$GIT_VERSION" ] || fatal "could not determine GIT_VERSION" 48 | 49 | BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" 50 | [ -n "$BUILD_TIME" ] || fatal "could not determine BUILD_TIME" 51 | 52 | case "$2" in 53 | all) _build_binary linux amd64; 54 | _build_binary linux arm64; 55 | _build_binary darwin amd64; 56 | _build_binary darwin arm64; 57 | _build_binary windows amd64; 58 | _build_binary windows arm64;; 59 | linux-amd64) _build_binary linux amd64;; 60 | linux-arm64) _build_binary linux arm64;; 61 | darwin-amd64) _build_binary darwin amd64;; 62 | darwin-arm64) _build_binary darwin arm64;; 63 | windows-amd64) _build_binary windows amd64;; 64 | windows-arm64) _build_binary windows arm64;; 65 | *) fatal "unrecognized os-arch: $2";; 66 | esac 67 | 68 | info "generating checksums.txt ..." 69 | _generate_checksums 70 | 71 | info "listing $OUTDIR/" 72 | ls -lh $OUTDIR 73 | } 74 | 75 | main "$@" 76 | -------------------------------------------------------------------------------- /web/app/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | fatal() { echo "Fatal: $*" 1>&2; exit 1; } 4 | 5 | usage() { 6 | cat</dev/null || fatal "curl not found" 46 | command -v sha256sum >/dev/null || fatal "sha256sum not found" 47 | 48 | while IFS=' ' read -r HASH SRC; do 49 | local DST=".vendor/$(basename $SRC)" 50 | _vendor_get_verify "$SRC" "$DST" "$HASH" 51 | case "$DST" in 52 | *.js) cat "$DST" >> vendor.js ;; 53 | *.css) cat "$DST" >> vendor.css ;; 54 | esac 55 | done < <(_vendor_files) 56 | sha256sum vendor.js 57 | sha256sum vendor.css 58 | } 59 | 60 | _tailwind() { 61 | # tailwindcss v3.1.6 62 | OPTS="$@" 63 | command -v tailwindcss >/dev/null || fatal "tailwindcss not found" 64 | [ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found" 65 | [ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found" 66 | tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css 67 | } 68 | 69 | main() { 70 | cd $(dirname $(realpath $0)) 71 | case $1 in 72 | ""|-h|--help|help) usage;; 73 | all) _vendor; _tailwind;; 74 | vendor) _vendor;; 75 | tailwind) shift; _tailwind $@;; 76 | *) fatal "unrecognized command: $1";; 77 | esac 78 | } 79 | 80 | main "$@" 81 | -------------------------------------------------------------------------------- /web/app/settings-stats.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 | 5 |
6 |
7 |
8 |
Counts
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 | ` 24 | 25 | export default { 26 | emits: ['finder-open'], 27 | data() { 28 | return { 29 | counts: {}, 30 | stats: [ 31 | ['notes', 'Notes', 'All notes', '/api/raw/list?color=true&prefix=label&sort=alpha'], 32 | ['labels', 'Label notes', 'Notes with one-word titles', '/api/raw/list?color=true&labels=true&sort=alpha'], 33 | ['orphans', 'Orphan notes', 'Notes without incoming or outgoing links', '/api/raw/list?color=true&orphans=true&sort=alpha'], 34 | ['links', 'Links', 'Count of links', '/api/raw/links?color=true'], 35 | ['dangling', 'Dangling links', 'Count of broken links', '/api/raw/links?color=true&dangling=true'], 36 | ['lines', 'Lines', 'Count of lines spanning all notes', '/api/raw/lines?color=true&prefix=title'], 37 | ['words', 'Words', 'Count of words spanning all notes', null], 38 | ['chars', 'Characters', 'Count of characters spanning all notes', null], 39 | ], 40 | } 41 | }, 42 | methods: { 43 | getCounts() { 44 | fetch('/api/raw/stats') 45 | .then(r => r.ok ? r.text() : r.text().then(e => Promise.reject(e))) 46 | .then(text => { 47 | this.counts = text.trim().split('\n').reduce((dict, line) => { 48 | const [key, val] = line.split(' '); dict[key] = val; 49 | return dict; 50 | }, {}); 51 | }) 52 | .catch(e => { 53 | console.error(e.Error); 54 | }); 55 | }, 56 | }, 57 | created() { 58 | this.getCounts(); 59 | }, 60 | template: t 61 | } 62 | -------------------------------------------------------------------------------- /web/app/periodic.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 3 |
4 |
7 | 8 |
9 |
11 | Weekly note 12 |
13 |
15 | Daily note 16 |
17 |
18 |
19 |
20 | ` 21 | 22 | import DatePicker from './datepicker.js' 23 | export default { 24 | components: { DatePicker }, 25 | emits: ['note-daily', 'note-weekly', 'periodic-close'], 26 | data() { 27 | return { 28 | showDatePicker: false, 29 | periodicNoteDate: null, 30 | periodicNoteDates: {}, 31 | } 32 | }, 33 | methods: { 34 | fetchPeriodicNoteDates() { 35 | fetch('/api/raw/list?prefix=ctime&date=2006-01-02T15:04:05') 36 | .then(response => response.text()) 37 | .then(text => { 38 | const dates = text.split('\n').reduce((acc, line) => { 39 | const parts = line.split(' '); 40 | if (parts.length > 1) { 41 | const date = parts[1].split('T')[0]; 42 | const time = parts[1].split('T')[1]; 43 | if (time === '00:00:00') { 44 | if (!acc[date]) acc[date] = []; 45 | if (!acc[date].includes('daily')) acc[date].push('daily'); 46 | } else if (time === '00:00:01') { 47 | if (!acc[date]) acc[date] = []; 48 | if (!acc[date].includes('weekly')) acc[date].push('weekly'); 49 | } 50 | } 51 | return acc; 52 | }, {}); 53 | this.periodicNoteDates = dates; 54 | this.showDatePicker = true; 55 | }); 56 | }, 57 | }, 58 | created() { 59 | this.fetchPeriodicNoteDates(); 60 | }, 61 | template: t 62 | } 63 | -------------------------------------------------------------------------------- /tests/weekly/weekly.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | function getISOWeekNum(date) { 4 | const tempDate = new Date(date.getTime()); 5 | tempDate.setHours(0, 0, 0, 0); 6 | tempDate.setDate(tempDate.getDate() + 3 - (tempDate.getDay() + 6) % 7); 7 | const week1 = new Date(tempDate.getFullYear(), 0, 4); 8 | return 1 + Math.round(((tempDate.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7); 9 | } 10 | 11 | function getSunWeekNum(date) { 12 | const sunday = new Date(date); 13 | sunday.setDate(date.getDate() - date.getDay()); 14 | const firstSunday = new Date(sunday.getFullYear(), 0, 1); 15 | if (firstSunday.getDay() !== 0) firstSunday.setMonth(0, 1 + (7 - firstSunday.getDay()) % 7); 16 | return 1 + Math.floor((sunday - firstSunday) / 604800000); 17 | } 18 | 19 | function formatDate(date, format) { 20 | const padZero = (num) => { return num.toString().padStart(2, '0'); } 21 | const replacements = { 22 | '%Y': date.getFullYear(), 23 | '%m': padZero(date.getMonth() + 1), 24 | '%d': padZero(date.getDate()), 25 | '%H': padZero(date.getHours()), 26 | '%M': padZero(date.getMinutes()), 27 | '%S': padZero(date.getSeconds()), 28 | '%a': date.toLocaleString('en-US', { weekday: 'short' }), 29 | '%b': date.toLocaleString('en-US', { month: 'short' }), 30 | '%u': date.getDay() === 0 ? 7 : date.getDay(), 31 | '%U': padZero(getSunWeekNum(date)), 32 | '%V': padZero(getISOWeekNum(date)), 33 | }; 34 | return format.replace(/%[a-zA-Z]/g, match => replacements[match]); 35 | } 36 | 37 | function weeklyTest(customDate = null) { 38 | const date = customDate ? new Date(customDate) : new Date(); 39 | const epoch = date.getTime() / 1000; 40 | const day = date.getDay() === 0 ? 7 : date.getDay(); 41 | 42 | const diff = (day - startOfWeek + 7) % 7; 43 | const weekBegEpoch = epoch - (diff * 86400); 44 | const weekBegDate = new Date(weekBegEpoch * 1000); 45 | const weekBegStr = formatDate(weekBegDate, '%a %b %d'); 46 | const weekEndEpoch = weekBegEpoch + (6 * 86400); 47 | const weekEndDate = new Date(weekEndEpoch * 1000); 48 | const weekEndStr = formatDate(weekEndDate, '%a %b %d'); 49 | 50 | const year = formatDate(weekBegDate, '%Y'); 51 | const weekFmt = startOfWeek === 0 ? '%U' : '%V'; 52 | const weekNum = formatDate(weekBegDate, weekFmt); 53 | 54 | const cdate = formatDate(weekBegDate, '%Y-%m-%d'); 55 | const title = `# ${year}: Week${weekNum} (${weekBegStr} - ${weekEndStr})`; 56 | const dateInput = `${customDate}:${startOfWeek}`; 57 | 58 | console.log(`${dateInput} ${cdate} ${title}`); 59 | } 60 | 61 | 62 | let startOfWeek = null; 63 | const args = process.argv.slice(2); 64 | args.forEach(arg => { 65 | const [date, weekstart] = arg.split(':'); 66 | startOfWeek = parseInt(weekstart); 67 | weeklyTest(date); 68 | }); 69 | 70 | -------------------------------------------------------------------------------- /web/app/settings-keybinds.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 |
17 |
18 |
19 |
20 | ` 21 | 22 | export default { 23 | data() { 24 | return { 25 | sections: [ 26 | { 27 | name: 'global', 28 | title: 'Global', 29 | entries: [ 30 | ['space n n', 'Open new note for editing'], 31 | ['space n d', 'Open new or existing daily note'], 32 | ['space n w', 'Open new or existing weekly note'], 33 | ['space n l', 'Finder: List with prefixed label, sorted alphabetically'], 34 | ['space n c', 'Finder: List with prefixed date created, sorted by ctime'], 35 | ['space n m', 'Finder: List with prefixed date modified, sorted by mtime'], 36 | ['space n k', 'Finder: Links related notes to active note (or all if none open)'], 37 | ['space n s', 'Finder: Full text search across all notes'], 38 | ['space n g', 'Open fullscreen force graph view'], 39 | ] 40 | }, 41 | { 42 | name: 'finder', 43 | title: 'Finder', 44 | entries: [ 45 | ['C-p', 'Toggle preview'], 46 | ['↓ | C-j', 'Select next entry (down)'], 47 | ['↑ | C-k', 'Select previous entry (up)'], 48 | ['Enter', 'Submit selected entry'], 49 | ['Esc', 'Dismiss finder'], 50 | ] 51 | }, 52 | { 53 | name: 'tabs', 54 | title: 'Note tabs', 55 | entries: [ 56 | ['C-h', 'Switch to note tab on the left of the active note tab'], 57 | ['C-l', 'Switch to note tab on the right of the active note tab'], 58 | ['C-^ | C-6', 'Switch to previously active tab'], 59 | ] 60 | }, 61 | ], 62 | } 63 | }, 64 | template: t 65 | } 66 | -------------------------------------------------------------------------------- /tests/runtime.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | _curl_jq() { curl -qs http://localhost:8881/${1} | jq -r "${2}" ; } 6 | 7 | _os_arch() { 8 | uname -sm | tr A-Z a-z | sed 's/ /\//;s/x86_64/amd64/;s/aarch64/arm64/' 9 | } 10 | 11 | setup_file() { 12 | command -v jq >/dev/null 13 | command -v curl >/dev/null 14 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 15 | export EXPECTED_PLATFORM="$(_os_arch)" 16 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 17 | [ "$(pgrep -x notesium)" == "" ] 18 | } 19 | 20 | @test "runtime: start with custom port, stop-on-idle and no-check" { 21 | run notesium web --port=8881 --stop-on-idle --no-check & 22 | echo "$output" 23 | } 24 | 25 | @test "runtime: home, version, platform" { 26 | run _curl_jq 'api/runtime' '.home' 27 | echo "$output" 28 | [ $status -eq 0 ] 29 | [ "${lines[0]}" == "$NOTESIUM_DIR" ] 30 | 31 | run _curl_jq 'api/runtime' '.version' 32 | echo "$output" 33 | [ $status -eq 0 ] 34 | [ "${lines[0]}" != "null" ] 35 | 36 | run _curl_jq 'api/runtime' '.platform' 37 | echo "$output" 38 | [ $status -eq 0 ] 39 | [ "${lines[0]}" == "$EXPECTED_PLATFORM" ] 40 | } 41 | 42 | @test "runtime: web" { 43 | run _curl_jq 'api/runtime' '.web | to_entries[] | "\(.key): \(.value)"' 44 | echo "$output" 45 | [ $status -eq 0 ] 46 | [ "${#lines[@]}" -eq 4 ] 47 | [ "${lines[0]}" == "webroot: embedded" ] 48 | [ "${lines[1]}" == "writable: false" ] 49 | [ "${lines[2]}" == "stop-on-idle: true" ] 50 | [ "${lines[3]}" == "daily-version-check: false" ] 51 | } 52 | 53 | @test "runtime: build" { 54 | run _curl_jq 'api/runtime' '.build | to_entries[] | "\(.key): \(.value)"' 55 | echo "$output" 56 | [ $status -eq 0 ] 57 | [ "${#lines[@]}" -eq 4 ] 58 | [[ "${lines[0]}" =~ "gitversion: v" ]] 59 | [[ "${lines[1]}" =~ "buildtime:" ]] 60 | [[ "${lines[2]}" =~ "goversion: go" ]] 61 | [[ "${lines[3]}" =~ "latest-release-url: http" ]] 62 | } 63 | 64 | @test "runtime: memory" { 65 | run _curl_jq 'api/runtime' '.memory | to_entries[] | "\(.key): \(.value)"' 66 | echo "$output" 67 | [ $status -eq 0 ] 68 | [ "${#lines[@]}" -eq 6 ] 69 | [[ "${lines[0]}" =~ "alloc:" ]] 70 | [[ "${lines[1]}" =~ "total-alloc:" ]] 71 | [[ "${lines[2]}" =~ "sys:" ]] 72 | [[ "${lines[3]}" =~ "lookups:" ]] 73 | [[ "${lines[4]}" =~ "mallocs:" ]] 74 | [[ "${lines[5]}" =~ "frees:" ]] 75 | } 76 | 77 | @test "runtime: stop by sending terminate signal" { 78 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 79 | run pgrep -x notesium 80 | echo "$output" 81 | echo "could not get pid" 82 | [ $status -eq 0 ] 83 | 84 | run kill "$(pgrep -x notesium)" 85 | echo "$output" 86 | [ $status -eq 0 ] 87 | 88 | run pgrep -x notesium 89 | echo "$output" 90 | [ $status -eq 1 ] 91 | } 92 | 93 | -------------------------------------------------------------------------------- /web/app/confirm.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 29 | ` 30 | 31 | export default { 32 | data() { 33 | return { 34 | config: {}, 35 | visible: false, 36 | resolve: null, 37 | }; 38 | }, 39 | methods: { 40 | open(config) { 41 | this.config = config; 42 | this.visible = true; 43 | this.$nextTick(() => { this.$refs.modalAutoFocus.focus(); }); 44 | return new Promise((resolve) => { 45 | this.resolve = resolve; 46 | }); 47 | }, 48 | close(resolve) { 49 | this.visible = false; 50 | this.resolve(resolve); 51 | }, 52 | }, 53 | template: t 54 | } 55 | -------------------------------------------------------------------------------- /web/app/preview.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 | ` 4 | 5 | export default { 6 | props: ['filename', 'lineNumber', 'clickableLinks', 'appendIncomingLinks'], 7 | emits: ['note-open'], 8 | methods: { 9 | fetchNote() { 10 | fetch("/api/notes/" + this.filename) 11 | .then(response => response.json()) 12 | .then(note => { 13 | if (this.appendIncomingLinks && note.IncomingLinks?.length) { 14 | const sortedIncomingLinks = note.IncomingLinks.sort((a, b) => a.Title.localeCompare(b.Title)); 15 | const linksMd = sortedIncomingLinks.map(link => `- [${link.Title}](${link.Filename})`).join('\n'); 16 | this.cm.setValue(`${note.Content.replace(/\n+$/, '')}\n\n---\n\n**Incoming links**\n\n${linksMd}`); 17 | } else { 18 | this.cm.setValue(note.Content); 19 | this.lineNumberHL(); 20 | } 21 | }); 22 | }, 23 | lineNumberHL() { 24 | if (!Number.isInteger(this.lineNumber) || this.lineNumber === undefined) return; 25 | this.$nextTick(() => { 26 | this.cm.setOption("styleActiveLine", true); 27 | this.cm.setCursor({line: this.lineNumber - 1, ch: 0}); 28 | }); 29 | }, 30 | }, 31 | mounted() { 32 | this.cm = new CodeMirror(this.$refs.preview, { 33 | value: '', 34 | readOnly: true, 35 | styleActiveLine: false, 36 | lineWrapping: this.$notesiumState.editorLineWrapping, 37 | theme: 'notesium-light', 38 | mode: { 39 | name: "gfm", 40 | highlightFormatting: true, 41 | }, 42 | }); 43 | 44 | if (this.clickableLinks) { 45 | this.cm.on('mousedown', (cm, e) => { 46 | let el = e.composedPath()[0]; 47 | if (el.classList.contains('cm-link') || el.classList.contains('cm-url')) { 48 | const getNextNSibling = (element, n) => { for (; n > 0 && element; n--, element = element.nextElementSibling); return element; }; 49 | 50 | if (el.classList.contains('cm-formatting')) { 51 | switch (el.textContent) { 52 | case '[': el = getNextNSibling(el, 4); break; 53 | case ']': el = getNextNSibling(el, 2); break; 54 | case '(': el = getNextNSibling(el, 1); break; 55 | case ')': el = el.previousElementSibling; break; 56 | default: return; 57 | } 58 | if (!el?.classList.contains('cm-url')) return; 59 | } 60 | 61 | if (el.classList.contains('cm-link')) { 62 | const potentialUrlElement = getNextNSibling(el, 3); 63 | el = potentialUrlElement?.classList.contains('cm-url') ? potentialUrlElement : el; 64 | } 65 | 66 | const link = el.textContent; 67 | const isMdFile = /^[0-9a-f]{8}\.md$/i.test(link); 68 | const hasProtocol = /^[a-zA-Z]+:\/\//.test(link); 69 | (isMdFile) ? this.$emit('note-open', link) : window.open(hasProtocol ? link : 'https://' + link, '_blank'); 70 | e.preventDefault(); 71 | } 72 | }); 73 | } 74 | 75 | this.fetchNote(); 76 | }, 77 | watch: { 78 | filename: function() { this.fetchNote(); }, 79 | lineNumber: function() { this.lineNumberHL(); }, 80 | }, 81 | template: t 82 | } 83 | -------------------------------------------------------------------------------- /tests/web.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | _curl() { curl -qs http://localhost:8881/${1} ; } 6 | _curl_jq() { curl -qs http://localhost:8881/${1} | jq -r "${2}" ; } 7 | 8 | setup_file() { 9 | command -v jq >/dev/null 10 | command -v curl >/dev/null 11 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 12 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 13 | [ "$(pgrep -x notesium)" == "" ] 14 | } 15 | 16 | @test "web: start with custom port and stop-on-idle" { 17 | run notesium web --port=8881 --stop-on-idle & 18 | echo "$output" 19 | } 20 | 21 | @test "web: api/heartbeat" { 22 | run _curl 'api/heartbeat' 23 | echo "$output" 24 | [ $status -eq 0 ] 25 | [ "${lines[0]}" == "Heartbeat received." ] 26 | } 27 | 28 | @test "web: api/notes count" { 29 | run _curl_jq 'api/notes' '. | length' 30 | echo "$output" 31 | [ $status -eq 0 ] 32 | [ "${lines[0]}" == "8" ] 33 | } 34 | 35 | @test "web: api/notes filenames and titles" { 36 | run _curl_jq 'api/notes' '.[] | "\(.Filename) \(.Title)"' 37 | echo "$output" 38 | [ $status -eq 0 ] 39 | [ "${#lines[@]}" -eq 8 ] 40 | assert_line "6421460b.md book" 41 | assert_line "642146c7.md physicist" 42 | assert_line "64214930.md quantum mechanics" 43 | assert_line "64214a1d.md richard feynman" 44 | assert_line "642176a6.md lorem ipsum" 45 | assert_line "64217712.md empty note" 46 | assert_line "64218087.md surely you're joking mr. feynman" 47 | assert_line "64218088.md albert einstein" 48 | } 49 | 50 | @test "web: api/notes specific note incoming link filename" { 51 | run _curl_jq 'api/notes' '.["64214a1d.md"].IncomingLinks[].Filename' 52 | echo "$output" 53 | [ $status -eq 0 ] 54 | [ "${lines[0]}" == "64218087.md" ] 55 | } 56 | 57 | @test "web: api/notes specific note incoming link title" { 58 | run _curl_jq 'api/notes' '.["64214a1d.md"].IncomingLinks[].Title' 59 | echo "$output" 60 | [ $status -eq 0 ] 61 | [ "${lines[0]}" == "surely you're joking mr. feynman" ] 62 | } 63 | 64 | @test "web: api/notes specific note outgoing link titles" { 65 | run _curl_jq 'api/notes' '.["64214a1d.md"].OutgoingLinks[].Title' 66 | echo "$output" 67 | [ $status -eq 0 ] 68 | [ "${#lines[@]}" -eq 2 ] 69 | assert_line "physicist" 70 | assert_line "quantum mechanics" 71 | } 72 | 73 | @test "web: api/notes/filename content" { 74 | run _curl_jq 'api/notes/642146c7.md' '.Content' 75 | echo "$output" 76 | [ $status -eq 0 ] 77 | [ "${lines[0]}" == "# physicist" ] 78 | } 79 | 80 | @test "web: api/notes/filename not found" { 81 | run _curl_jq 'api/notes/aaaaaaaa.md' '.Error' 82 | echo "$output" 83 | [ $status -eq 0 ] 84 | [ "${lines[0]}" == "Note not found" ] 85 | } 86 | 87 | @test "web: stop by sending terminate signal" { 88 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 89 | run pgrep -x notesium 90 | echo "$output" 91 | echo "could not get pid" 92 | [ $status -eq 0 ] 93 | 94 | run kill "$(pgrep -x notesium)" 95 | echo "$output" 96 | [ $status -eq 0 ] 97 | 98 | run pgrep -x notesium 99 | echo "$output" 100 | [ $status -eq 1 ] 101 | } 102 | 103 | -------------------------------------------------------------------------------- /tests/links.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | setup_file() { 6 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 7 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 8 | } 9 | 10 | @test "links: default without filename" { 11 | run notesium links 12 | echo "$output" 13 | [ $status -eq 0 ] 14 | [ "${#lines[@]}" -eq 7 ] 15 | assert_line "64218088.md:3: albert einstein → physicist" 16 | assert_line "64218088.md:7: albert einstein → quantum mechanics" 17 | assert_line "64214a1d.md:3: richard feynman → physicist" 18 | assert_line "64214a1d.md:5: richard feynman → quantum mechanics" 19 | assert_line "64218087.md:3: surely you're joking mr. feynman → book" 20 | assert_line "64218087.md:3: surely you're joking mr. feynman → richard feynman" 21 | assert_line "64218087.md:3: surely you're joking mr. feynman → 12345678.md" 22 | } 23 | 24 | @test "links: default with filename" { 25 | run notesium links 64214a1d.md 26 | echo "$output" 27 | [ $status -eq 0 ] 28 | [ "${#lines[@]}" -eq 3 ] 29 | assert_line "642146c7.md:1: outgoing physicist" 30 | assert_line "64214930.md:1: outgoing quantum mechanics" 31 | assert_line "64218087.md:3: incoming surely you're joking mr. feynman" 32 | } 33 | 34 | @test "links: outgoing with filename" { 35 | run notesium links --outgoing 64214a1d.md 36 | echo "$output" 37 | [ $status -eq 0 ] 38 | [ "${#lines[@]}" -eq 2 ] 39 | assert_line "642146c7.md:1: physicist" 40 | assert_line "64214930.md:1: quantum mechanics" 41 | } 42 | 43 | @test "links: outgoing without filename" { 44 | run notesium links --outgoing 45 | echo "$output" 46 | [ $status -eq 1 ] 47 | [[ "${lines[0]}" =~ 'filename is required' ]] 48 | } 49 | 50 | @test "links: incoming with filename" { 51 | run notesium links --incoming 642146c7.md 52 | echo "$output" 53 | [ $status -eq 0 ] 54 | [ "${#lines[@]}" -eq 2 ] 55 | assert_line "64218088.md:3: albert einstein" 56 | assert_line "64214a1d.md:3: richard feynman" 57 | } 58 | 59 | @test "links: incoming without filename" { 60 | run notesium links --incoming 61 | echo "$output" 62 | [ $status -eq 1 ] 63 | [[ "${lines[0]}" =~ 'filename is required' ]] 64 | } 65 | 66 | @test "links: incoming and outgoing with filename" { 67 | run notesium links --incoming --outgoing 64214a1d.md 68 | echo "$output" 69 | [ $status -eq 0 ] 70 | [ "${#lines[@]}" -eq 3 ] 71 | assert_line "642146c7.md:1: outgoing physicist" 72 | assert_line "64214930.md:1: outgoing quantum mechanics" 73 | assert_line "64218087.md:3: incoming surely you're joking mr. feynman" 74 | } 75 | 76 | @test "links: incoming and outgoing without filename" { 77 | run notesium links --incoming --outgoing 78 | echo "$output" 79 | [ $status -eq 1 ] 80 | [[ "${lines[0]}" =~ 'filename is required' ]] 81 | } 82 | 83 | @test "links: dangling with filename" { 84 | run notesium links --dangling 64218087.md 85 | echo "$output" 86 | [ $status -eq 1 ] 87 | [[ "${lines[0]}" =~ 'filename not supported' ]] 88 | } 89 | 90 | @test "links: dangling without filename" { 91 | run notesium links --dangling 92 | echo "$output" 93 | [ $status -eq 0 ] 94 | [ "${lines[0]}" == "64218087.md:3: surely you're joking mr. feynman → 12345678.md" ] 95 | } 96 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // 1:semver (2:major 3:minor 4:patch 5:prerelease 6:prereleaseV) 7:commits 8:hash 9:dirty 13 | 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)?$`) 14 | 15 | var latestReleaseURL = "https://api.github.com/repos/alonswartz/notesium/releases/latest" 16 | 17 | type releaseInfo struct { 18 | Version string `json:"-"` 19 | TagName string `json:"tag_name"` 20 | HTMLURL string `json:"html_url"` 21 | PublishedAt string `json:"published_at"` 22 | } 23 | 24 | func getVersion(gitVersion string) string { 25 | if matches := gitVersionRegex.FindStringSubmatch(gitVersion); matches != nil { 26 | semver := matches[1] 27 | commits := matches[7] 28 | isDirty := matches[9] == "-dirty" 29 | 30 | if isDirty { 31 | return fmt.Sprintf("%s+%s-dirty", semver, commits) 32 | } 33 | 34 | if commits != "0" { 35 | return fmt.Sprintf("%s+%s", semver, commits) 36 | } 37 | 38 | return semver 39 | } 40 | return "0.0.0-dev" 41 | } 42 | 43 | func getLatestReleaseInfo() (releaseInfo, error) { 44 | var release releaseInfo 45 | 46 | req, err := http.NewRequest("GET", latestReleaseURL, nil) 47 | if err != nil { 48 | return release, fmt.Errorf("error creating request: %s", err) 49 | } 50 | 51 | req.Header.Set("Accept", "application/vnd.github+json") 52 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28") 53 | 54 | client := &http.Client{} 55 | resp, err := client.Do(req) 56 | if err != nil { 57 | return release, fmt.Errorf("error making request: %s", err) 58 | } 59 | defer resp.Body.Close() 60 | 61 | if resp.StatusCode != http.StatusOK { 62 | return release, fmt.Errorf("error status code: %d", resp.StatusCode) 63 | } 64 | 65 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 66 | return release, fmt.Errorf("error decoding response: %s", err) 67 | } 68 | 69 | if release.TagName == "" || release.HTMLURL == "" || release.PublishedAt == "" { 70 | return release, fmt.Errorf("missing required field in response") 71 | } 72 | 73 | release.Version = strings.TrimPrefix(release.TagName, "v") 74 | return release, nil 75 | } 76 | 77 | func compareVersions(v1, v2 string) int { 78 | // returns -1 if v1 < v2, 1 if v1 > v2, and 0 if they are equal. 79 | // v1: +build is ignored. -prerelease will decrement patch 80 | // v2: +build is ignored. -prerelease not supported (set to 0.0.0) 81 | normalize := func(v string, handlePreRelease bool) []int { 82 | v = strings.SplitN(v, "+", 2)[0] 83 | parts := strings.SplitN(v, "-", 2) 84 | isPreRelease := len(parts) > 1 85 | 86 | if isPreRelease && !handlePreRelease { 87 | return []int{0, 0, 0} 88 | } 89 | 90 | versionParts := strings.Split(parts[0], ".") 91 | for len(versionParts) < 3 { 92 | versionParts = append(versionParts, "0") 93 | } 94 | 95 | intParts := make([]int, 3) 96 | for i, part := range versionParts { 97 | intParts[i], _ = strconv.Atoi(part) 98 | } 99 | 100 | if isPreRelease && handlePreRelease { 101 | intParts[2]-- 102 | } 103 | 104 | return intParts 105 | } 106 | 107 | v1Parts := normalize(v1, true) 108 | v2Parts := normalize(v2, false) 109 | 110 | for i := 0; i < 3; i++ { 111 | if v1Parts[i] < v2Parts[i] { 112 | return -1 113 | } else if v1Parts[i] > v2Parts[i] { 114 | return 1 115 | } 116 | } 117 | 118 | return 0 119 | } 120 | -------------------------------------------------------------------------------- /tests/lines.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | setup_file() { 6 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 7 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 8 | } 9 | 10 | @test "lines: default" { 11 | run notesium lines 12 | echo "$output" 13 | [ $status -eq 0 ] 14 | assert_line "6421460b.md:1: # book" 15 | assert_line "642146c7.md:1: # physicist" 16 | assert_line "64214930.md:3: a fundamental theory in physics that provides a description of the" 17 | assert_line "64214930.md:5: particles." 18 | } 19 | 20 | @test "lines: prefix title" { 21 | run notesium lines --prefix=title 22 | echo "$output" 23 | [ $status -eq 0 ] 24 | assert_line "6421460b.md:1: book # book" 25 | assert_line "642146c7.md:1: physicist # physicist" 26 | assert_line "64214930.md:3: quantum mechanics a fundamental theory in physics that provides a description of the" 27 | } 28 | 29 | @test "lines: filter case insensitive" { 30 | run notesium lines --filter='Einstein' 31 | echo "$output" 32 | [ $status -eq 0 ] 33 | [ "${#lines[@]}" -eq 3 ] 34 | assert_line "64218088.md:1: # albert einstein" 35 | assert_line "64218088.md:3: albert einstein was a german-born theoretical [physicist](642146c7.md)," 36 | assert_line "64218088.md:5: physicists of all time. einstein is best known for developing the" 37 | } 38 | 39 | @test "lines: filter AND" { 40 | run notesium lines --filter='einstein albert' 41 | echo "$output" 42 | [ $status -eq 0 ] 43 | [ "${#lines[@]}" -eq 2 ] 44 | assert_line "64218088.md:1: # albert einstein" 45 | assert_line "64218088.md:3: albert einstein was a german-born theoretical [physicist](642146c7.md)," 46 | } 47 | 48 | @test "lines: filter OR" { 49 | run notesium lines --filter='american|german' 50 | echo "$output" 51 | [ $status -eq 0 ] 52 | [ "${#lines[@]}" -eq 2 ] 53 | assert_line "64214a1d.md:3: richard phillips feynman was an american theoretical [physicist](642146c7.md)," 54 | assert_line "64218088.md:3: albert einstein was a german-born theoretical [physicist](642146c7.md)," 55 | } 56 | 57 | @test "lines: filter NOT" { 58 | run notesium lines --filter='einstein !physicist' 59 | echo "$output" 60 | [ $status -eq 0 ] 61 | [ "${#lines[@]}" -eq 1 ] 62 | assert_line "64218088.md:1: # albert einstein" 63 | } 64 | 65 | @test "lines: filter OR AND NOT" { 66 | run notesium lines --filter='theory|model quantum !development' 67 | echo "$output" 68 | [ $status -eq 0 ] 69 | [ "${#lines[@]}" -eq 1 ] 70 | assert_line "64214a1d.md:5: [quantum mechanics](64214930.md), the theory of quantum electrodynamics," 71 | } 72 | 73 | @test "lines: filter OR AND NOT and prefix title" { 74 | run notesium lines --filter='theory|model quantum !development' --prefix=title 75 | echo "$output" 76 | [ $status -eq 0 ] 77 | [ "${#lines[@]}" -eq 1 ] 78 | assert_line "64214a1d.md:5: richard feynman [quantum mechanics](64214930.md), the theory of quantum electrodynamics," 79 | } 80 | 81 | @test "lines: filter quoted vs non-quoted" { 82 | run notesium lines --filter='theory of quantum' 83 | [ $status -eq 0 ] 84 | [ "${#lines[@]}" -eq 2 ] 85 | assert_line "64214a1d.md:5: [quantum mechanics](64214930.md), the theory of quantum electrodynamics," 86 | assert_line "64218088.md:7: to the development of the theory of [quantum mechanics](64214930.md)." 87 | 88 | run notesium lines --filter='"theory of quantum"' 89 | [ $status -eq 0 ] 90 | [ "${#lines[@]}" -eq 1 ] 91 | assert_line "64214a1d.md:5: [quantum mechanics](64214930.md), the theory of quantum electrodynamics," 92 | } 93 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type Link struct { 16 | Filename string 17 | Title string 18 | LineNumber int 19 | } 20 | 21 | type Note struct { 22 | Filename string 23 | Title string 24 | IsLabel bool 25 | OutgoingLinks []*Link 26 | IncomingLinks []*Link 27 | Ctime time.Time 28 | Mtime time.Time 29 | Lines int 30 | Words int 31 | Chars int 32 | } 33 | 34 | var noteCache map[string]*Note 35 | var fileRegex = regexp.MustCompile(`^[0-9a-f]{8}\.md$`) 36 | var linkRegex = regexp.MustCompile(`\]\(([0-9a-f]{8}\.md)\)`) 37 | 38 | func populateCache(dir string) { 39 | if noteCache != nil { 40 | return 41 | } 42 | 43 | noteCache = make(map[string]*Note) 44 | files, err := os.ReadDir(dir) 45 | if err != nil { 46 | log.Fatalf("could not read directory: %s\n", err) 47 | } 48 | 49 | for _, file := range files { 50 | filename := file.Name() 51 | if !file.IsDir() && fileRegex.MatchString(filename) { 52 | note, err := readNote(dir, filename) 53 | if err != nil { 54 | log.Fatalf("could not read note: %s\n", err) 55 | } 56 | noteCache[filename] = note 57 | } 58 | } 59 | 60 | for _, note := range noteCache { 61 | for _, link := range note.OutgoingLinks { 62 | if targetNote, exists := noteCache[link.Filename]; exists { 63 | link.Title = targetNote.Title 64 | targetNote.IncomingLinks = append(targetNote.IncomingLinks, &Link{ 65 | Filename: note.Filename, 66 | Title: note.Title, 67 | LineNumber: link.LineNumber, 68 | }) 69 | } 70 | } 71 | } 72 | } 73 | 74 | func readNote(dir string, filename string) (*Note, error) { 75 | path := filepath.Join(dir, filename) 76 | file, err := os.Open(path) 77 | if err != nil { 78 | return nil, fmt.Errorf("could not open file: %s", err) 79 | } 80 | defer file.Close() 81 | 82 | info, err := file.Stat() 83 | if err != nil { 84 | return nil, fmt.Errorf("could not get file info: %s", err) 85 | } 86 | mtime := info.ModTime().Truncate(time.Second) 87 | 88 | hexTime := strings.TrimSuffix(filename, ".md") 89 | unixTime, err := strconv.ParseInt(hexTime, 16, 64) 90 | if err != nil { 91 | return nil, err 92 | } 93 | ctime := time.Unix(unixTime, 0) 94 | 95 | var title string 96 | var isLabel bool 97 | var outgoingLinks []*Link 98 | var lines, words, chars int 99 | 100 | scanner := bufio.NewScanner(file) 101 | lineNumber := 0 102 | for scanner.Scan() { 103 | lineNumber++ 104 | line := scanner.Text() 105 | if line != "" { 106 | lines++ 107 | words += len(strings.Fields(line)) 108 | chars += len(line) 109 | } 110 | if title == "" { 111 | title = strings.TrimPrefix(line, "# ") 112 | isLabel = len(strings.Fields(title)) == 1 113 | continue 114 | } 115 | matches := linkRegex.FindAllStringSubmatch(line, -1) 116 | for _, match := range matches { 117 | outgoingLinks = append(outgoingLinks, &Link{LineNumber: lineNumber, Filename: match[1]}) 118 | } 119 | } 120 | 121 | if err := scanner.Err(); err != nil { 122 | return nil, err 123 | } 124 | 125 | if title == "" { 126 | title = "untitled" 127 | } 128 | 129 | note := &Note{ 130 | Filename: filename, 131 | Title: title, 132 | IsLabel: isLabel, 133 | OutgoingLinks: outgoingLinks, 134 | Ctime: ctime, 135 | Mtime: mtime, 136 | Lines: lines, 137 | Words: words, 138 | Chars: chars, 139 | } 140 | 141 | return note, nil 142 | } 143 | -------------------------------------------------------------------------------- /web/app/cm-vim.js: -------------------------------------------------------------------------------- 1 | export function initCodeMirrorVimEx(notesiumState) { 2 | CodeMirror.Vim.defineEx('quit', 'q', (cm, cmd) => { 3 | const confirmIfModified = cmd.argString !== '!'; 4 | if (cm.quit) cm.quit(confirmIfModified); 5 | }); 6 | 7 | CodeMirror.Vim.defineEx('wq', '', (cm) => { 8 | if (cm.writequit) cm.writequit(); 9 | }); 10 | 11 | CodeMirror.Vim.defineEx('cmExecCommand', '', (cm, cmd) => { 12 | cm.execCommand(cmd.args[0]); 13 | }); 14 | CodeMirror.Vim.map('zo', ':cmExecCommand unfold', 'normal'); 15 | CodeMirror.Vim.map('zc', ':cmExecCommand fold', 'normal'); 16 | CodeMirror.Vim.map('zR', ':cmExecCommand unfoldAll', 'normal'); 17 | CodeMirror.Vim.map('zM', ':cmExecCommand foldAll', 'normal'); 18 | CodeMirror.Vim.map('za', ':cmExecCommand toggleFold', 'normal'); 19 | 20 | CodeMirror.Vim.defineEx('OpenLinkUnderCursor', '', (cm) => { 21 | if (!cm.openlink) return; 22 | const cursor = cm.getCursor(); 23 | const lineContent = cm.getLine(cursor.line); 24 | const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 25 | const urlLinkRegex = /(?:https?:\/\/|www\.)[^\s)]+/g; 26 | let match; 27 | let link = null; 28 | while ((match = mdLinkRegex.exec(lineContent)) !== null) { 29 | const start = match.index; 30 | const end = start + match[0].length; 31 | if (cursor.ch >= start && cursor.ch <= end) { link = match[2]; break; } 32 | } 33 | if (!link) { 34 | while ((match = urlLinkRegex.exec(lineContent)) !== null) { 35 | const start = match.index; 36 | const end = start + match[0].length; 37 | if (cursor.ch >= start && cursor.ch <= end) { link = match[0]; break; } 38 | } 39 | } 40 | if (link) cm.openlink(link); 41 | }); 42 | CodeMirror.Vim.map('ge', ':OpenLinkUnderCursor', 'normal'); 43 | CodeMirror.Vim.map('gx', ':OpenLinkUnderCursor', 'normal'); 44 | 45 | CodeMirror.Vim.defineEx('BodyKeyEvent', '', (cm, cmd) => { 46 | const key = cmd.args[0]; 47 | const code = cmd.args[1]; 48 | const ctrlKey = key.startsWith('', ':BodyKeyEvent Space', 'normal'); 54 | CodeMirror.Vim.map('', ':BodyKeyEvent KeyH', 'normal'); 55 | CodeMirror.Vim.map('', ':BodyKeyEvent KeyL', 'normal'); 56 | CodeMirror.Vim.map('', ':BodyKeyEvent Digit6', 'normal'); 57 | CodeMirror.Vim.map('', ':BodyKeyEvent KeyH', 'insert'); 58 | CodeMirror.Vim.map('', ':BodyKeyEvent KeyL', 'insert'); 59 | CodeMirror.Vim.map('', ':BodyKeyEvent Digit6', 'insert'); 60 | 61 | CodeMirror.Vim.defineOption('wrap', notesiumState.editorLineWrapping, 'boolean', [], (value, cm) => { 62 | if (cm) return; // option is global, do nothing for local 63 | if (value === undefined) return notesiumState.editorLineWrapping; 64 | notesiumState.editorLineWrapping = value; 65 | return value; 66 | }); 67 | 68 | CodeMirror.Vim.defineOption('conceal', notesiumState.editorConcealFormatting, 'boolean', [], (value, cm) => { 69 | if (cm) return; // option is global, do nothing for local 70 | if (value === undefined) return notesiumState.editorConcealFormatting; 71 | notesiumState.editorConcealFormatting = value; 72 | return value; 73 | }); 74 | 75 | CodeMirror.Vim.defineOption('fold', notesiumState.editorFoldGutter, 'boolean', [], (value, cm) => { 76 | if (cm) return; // option is global, do nothing for local 77 | if (value === undefined) return notesiumState.editorFoldGutter; 78 | notesiumState.editorFoldGutter = value; 79 | return value; 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /web/app/note-statusbar.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 |
4 | 5 | 6 |
7 | 8 |
9 |
10 | 12 |
13 |
14 | 16 | 18 | 20 | 42 |
43 |
44 | 45 |
46 | ` 47 | 48 | import Icon from './icon.js' 49 | export default { 50 | components: { Icon }, 51 | props: ['note', 'vimMode', 'hasFocus' ], 52 | emits: ['note-delete', 'finder-open'], 53 | computed: { 54 | vimModeText() { 55 | const modeText = { 'visual-linewise': 'v-line', 'visual-blockwise': 'v-block' }; 56 | return modeText[`${this.vimMode.mode}-${this.vimMode.subMode}`] || this.vimMode.mode; 57 | }, 58 | vimModeCls() { 59 | const modeCls = { normal: 'bg-slate-500', insert: 'bg-yellow-500', visual: 'bg-pink-500', replace: 'bg-red-500', command: 'bg-sky-500' }; 60 | return modeCls[this.vimMode.mode] 61 | }, 62 | }, 63 | template: t 64 | } 65 | -------------------------------------------------------------------------------- /web/graph/README.md: -------------------------------------------------------------------------------- 1 | As of version [0.5.3](https://github.com/alonswartz/notesium/blob/master/CHANGELOG.md#053), the `web/graph` has been rewritten and implemented 2 | in `web/app`, with all of same features (except cluster settings and 3 | darkmode), has tighter integration, and additional improvements. 4 | 5 | This graph implemention is backwards compatible, and is especially 6 | useful for Vim users who don't need the additional features, but instead 7 | just want a way to view the graph via a single `keybind` and have 8 | optionally configured the `notesium://` [URI protocol](#custom-uri-protocol) handler for the 9 | edit links available in the note preview side pane. 10 | 11 | ## Table of contents 12 | 13 | - [Features](#features) 14 | - [Screenshots](#screenshots) 15 | - [Download](#download) 16 | - [CLI](#cli) 17 | - [Vim](#vim) 18 | 19 | ## Features 20 | 21 | - Visual overview of notes structure with a force graph view. 22 | - Cluster nodes based on links, inferred from titles or creation date. 23 | - Adjust node size dynamically based on bi-directional link count. 24 | - Emphasize nodes and their links using search filter or node click. 25 | - Preview notes in a side panel. Open for editing via `notesium://` link. 26 | - Tweak forces such as repel force, collide radius, and strength. 27 | - Drag, pan, or zoom the graph for a better view or focus. 28 | - Customize label visibility or automatically scale per zoom level. 29 | 30 | ## Screenshots 31 | 32 | *Graph: display all notes and their links in a force graph view* 33 | ![image: force graph cluster links](https://www.notesium.com/images/screenshot-1688650369.png) 34 |
35 | 36 | *Graph: cluster notes based on their titles instead of links* 37 | ![image: force graph cluster titles](https://www.notesium.com/images/screenshot-1687865971.png) 38 |
39 | 40 | *Graph: filter notes with emphasized matches. preview note content (dark mode)* 41 | ![image: force graph note preview](https://www.notesium.com/images/screenshot-1690971723.png) 42 |
43 | 44 | *Graph: zoomed out large note collection (dark mode)* 45 | ![image: force graph zoom](https://www.notesium.com/images/screenshot-1682941869.png) 46 |
47 | 48 | ## Download 49 | 50 | As of version 0.5.3, the `web/graph` is no longer embedded in the 51 | release binary, so it needs to be downloaded separately and vendor/css 52 | files *handled* (depending on your preference). 53 | 54 | **Offline usage** 55 | 56 | Download vendor files and compile CSS (assumes Linux and [tailwindcss standalone-cli](https://tailwindcss.com/blog/standalone-cli)). 57 | 58 | ```bash 59 | git clone https://github.com/alonswartz/notesium.git 60 | cd notesium 61 | ./web/graph/make.sh all 62 | ``` 63 | 64 | **CDN usage** 65 | 66 | ```bash 67 | git clone https://github.com/alonswartz/notesium.git 68 | cd notesium 69 | $EDITOR web/graph/index.html 70 | ``` 71 | 72 | ```diff 73 | Notesium Graph 74 | - 79 | - 80 | - 81 | 82 | ``` 83 | 84 | ## CLI 85 | 86 | ```bash 87 | notesium --webroot=/path/to/notesium/web/graph --stop-on-idle --open-browser 88 | ``` 89 | 90 | ## Vim 91 | 92 | ```vim 93 | command! -bang NotesiumGraph 94 | \ let webroot = "/path/to/notesium/web/graph" | 95 | \ let options = "--webroot=".webroot." --stop-on-idle --open-browser" | 96 | \ execute ":silent !nohup notesium web ".options." > /dev/null 2>&1 &" 97 | 98 | nnoremap ng :NotesiumGraph 99 | ``` 100 | 101 | -------------------------------------------------------------------------------- /tests/delete.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | URL="http://localhost:8881" 6 | _get_jq() { curl -qs ${URL}/${1} | jq -r "${2}" ; } 7 | _delete_jq() { curl -qs -X DELETE -d "${2}" ${URL}/${1} | jq -r "${3}" ; } 8 | 9 | setup_file() { 10 | command -v jq >/dev/null 11 | command -v curl >/dev/null 12 | [ "$(pgrep -x notesium)" == "" ] 13 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 14 | run mkdir /tmp/notesium-test-corpus 15 | run cp $BATS_TEST_DIRNAME/fixtures/*.md /tmp/notesium-test-corpus/ 16 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 17 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 18 | } 19 | 20 | teardown_file() { 21 | if [ "$PAUSE" ]; then 22 | echo "# NOTESIUM_DIR=$NOTESIUM_DIR" >&3 23 | echo "# PAUSED: Press enter to continue with teardown... " >&3 24 | run read -p "paused: " choice 25 | fi 26 | run rm /tmp/notesium-test-corpus/*.md 27 | run rmdir /tmp/notesium-test-corpus 28 | } 29 | 30 | @test "delete: start with custom port, stop-on-idle, writable" { 31 | run notesium web --port=8881 --stop-on-idle --writable & 32 | echo "$output" 33 | } 34 | 35 | @test "delete: note that does not exist" { 36 | run _delete_jq 'api/notes/aaaaaaaa.md' '{"LastMtime": "2023-01-16T05:05:00+02:00"}' '.Error' 37 | echo "$output" 38 | [ "${lines[0]}" == "Note not found" ] 39 | } 40 | 41 | @test "delete: note with incorrect last-mtime" { 42 | run _delete_jq 'api/notes/642146c7.md' '{"LastMtime": "2023-01-16T05:05:00+02:00"}' '.Error' 43 | echo "$output" 44 | [ "${lines[0]}" == "Refusing to delete. File changed on disk." ] 45 | } 46 | 47 | @test "delete: note with malformed last-mtime" { 48 | run _delete_jq 'api/notes/642146c7.md' '{"LastMtime": "2023-01-16"}' '.Code' 49 | echo "$output" 50 | [ "${lines[0]}" == "400" ] 51 | } 52 | 53 | @test "delete: note with incoming links" { 54 | run _get_jq 'api/notes/642146c7.md' '.Mtime' 55 | LastMtime="$output" 56 | echo "$output" 57 | [ $status -eq 0 ] 58 | 59 | run _delete_jq 'api/notes/642146c7.md' '{"LastMtime": "'"$LastMtime"'"}' '.Error' 60 | echo "$output" 61 | [ "${lines[0]}" == "Refusing to delete. Note has IncomingLinks." ] 62 | } 63 | 64 | @test "delete: verify incoming links of linked note pre deletion" { 65 | run _get_jq 'api/notes/642146c7.md' '.IncomingLinks | length' 66 | echo "$output" 67 | [ $status -eq 0 ] 68 | [ "${lines[0]}" == "2" ] 69 | } 70 | 71 | @test "delete: note without incoming links" { 72 | run _get_jq 'api/notes/64218088.md' '.Mtime' 73 | LastMtime="$output" 74 | echo "$output" 75 | [ $status -eq 0 ] 76 | 77 | run _delete_jq 'api/notes/64218088.md' '{"LastMtime": "'"$LastMtime"'"}' '.Deleted' 78 | echo "$output" 79 | [ "${lines[0]}" == "true" ] 80 | } 81 | 82 | @test "delete: verify incoming links of linked note post deletion" { 83 | run _get_jq 'api/notes/642146c7.md' '.IncomingLinks | length' 84 | echo "$output" 85 | [ $status -eq 0 ] 86 | [ "${lines[0]}" == "1" ] 87 | } 88 | 89 | @test "delete: verify note removed from cache" { 90 | run _get_jq 'api/notes/64218088.md' '.Error' 91 | echo "$output" 92 | [ "${lines[0]}" == "Note not found" ] 93 | [ $status -eq 0 ] 94 | } 95 | 96 | @test "delete: verify note deleted on disk" { 97 | run cat $NOTESIUM_DIR/64218088.md 98 | echo "$output" 99 | [ $status -eq 1 ] 100 | } 101 | 102 | @test "delete: stop by sending terminate signal" { 103 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 104 | run pgrep -x notesium 105 | echo "$output" 106 | echo "could not get pid" 107 | [ $status -eq 0 ] 108 | 109 | run kill "$(pgrep -x notesium)" 110 | echo "$output" 111 | [ $status -eq 0 ] 112 | 113 | run pgrep -x notesium 114 | echo "$output" 115 | [ $status -eq 1 ] 116 | } 117 | 118 | -------------------------------------------------------------------------------- /web/app/ribbon.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 | 4 |
5 | 7 | 8 | 9 |
10 | 11 |
12 | 15 | 16 | 17 | 20 | 21 | 22 | 25 | 26 | 27 | 30 | 31 | 32 |
33 | 34 |
35 | 37 | 38 | 39 | 41 | 42 | 43 | 45 | 46 | 47 | 49 | 50 | 51 | 53 | 54 | 55 | 57 | 58 | 59 |
60 | 61 |
62 | 64 |
65 | 66 |
67 |
68 |
69 | 70 |
71 | ` 72 | 73 | import Icon from './icon.js' 74 | export default { 75 | components: { Icon }, 76 | props: ['versionCheck', 'showPeriodic'], 77 | emits: ['note-new', 'finder-open', 'periodic-open', 'settings-open', 'graph-open'], 78 | computed: { 79 | updateAvailable() { 80 | return this.versionCheck.comparison == '-1'; 81 | }, 82 | }, 83 | template: t 84 | } 85 | -------------------------------------------------------------------------------- /web/app/nav-tabs.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 7 |
8 |
9 | 10 |
11 |
21 |
22 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 | 31 |
32 | | 33 |
34 |
35 | ` 36 | 37 | import Icon from './icon.js' 38 | export default { 39 | components: { Icon }, 40 | props: ['tabs', 'activeTabId', 'previousTabId', 'notes'], 41 | emits: ['tab-activate', 'tab-move', 'tab-close', 'note-close'], 42 | data() { 43 | return { 44 | dragIndex: null, 45 | dragOverEnabled: true, 46 | } 47 | }, 48 | methods: { 49 | onDragStart(index) { 50 | this.$emit('tab-activate', this.tabs[index].id) 51 | this.dragIndex = index; 52 | }, 53 | onDragOver(overIndex) { 54 | if (!this.dragOverEnabled) return; 55 | if (this.dragIndex === null || this.dragIndex === overIndex) return; 56 | 57 | this.$emit('tab-move', this.tabs[this.dragIndex].id, overIndex); 58 | this.dragIndex = overIndex; 59 | 60 | this.dragOverEnabled = false; 61 | setTimeout(() => { this.dragOverEnabled = true; }, 300); 62 | }, 63 | onDrop() { 64 | this.dragIndex = null; 65 | }, 66 | onDragEnd() { 67 | this.dragIndex = null; 68 | }, 69 | isActive(tabId) { 70 | return this.activeTabId == tabId; 71 | }, 72 | handleClose(tabId, tabType) { 73 | if (tabType === 'note') { 74 | this.$emit('note-close', tabId); 75 | return; 76 | } 77 | this.$emit('tab-close', tabId); 78 | }, 79 | handleKeyPress(event) { 80 | if (event.target.tagName !== 'BODY') return 81 | 82 | if (event.ctrlKey && event.code == 'Digit6') { 83 | this.previousTabId && this.$emit('tab-activate', this.previousTabId); 84 | event.preventDefault(); 85 | return; 86 | } 87 | 88 | if (event.ctrlKey && (event.code == 'KeyH' || event.code == 'KeyL')) { 89 | const index = this.tabs.findIndex(t => t.id === this.activeTabId); 90 | if (index === -1) return; 91 | const movement = event.code === 'KeyL' ? 1 : -1; 92 | const newIndex = (index + movement + this.tabs.length) % this.tabs.length; 93 | this.$emit('tab-activate', this.tabs[newIndex].id); 94 | event.preventDefault(); 95 | return; 96 | } 97 | }, 98 | }, 99 | computed: { 100 | getTabs() { 101 | return this.tabs.map(tab => { 102 | if (tab.type === 'note') { 103 | const note = this.notes.find(n => n.Filename === tab.id); 104 | return { 105 | id: tab.id, 106 | type: tab.type, 107 | title: note.Title, 108 | titleHover: `${note.Title} (${note.Filename})`, 109 | isModified: note.isModified || false, 110 | }; 111 | } 112 | }); 113 | }, 114 | }, 115 | mounted() { 116 | document.addEventListener('keydown', this.handleKeyPress); 117 | }, 118 | beforeDestroy() { 119 | document.removeEventListener('keydown', this.handleKeyPress); 120 | }, 121 | template: t 122 | } 123 | -------------------------------------------------------------------------------- /web/app/finder.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 37 | ` 38 | import Preview from './preview.js' 39 | export default { 40 | components: { Preview }, 41 | props: ['uri', 'small', 'initialQuery'], 42 | emits: ['finder-selection'], 43 | data() { 44 | return { 45 | query: '', 46 | items: [], 47 | selected: 0, 48 | preview: true, 49 | } 50 | }, 51 | methods: { 52 | handleSelection(selected) { 53 | this.$emit('finder-selection', selected !== null ? this.filteredItems[selected] : null); 54 | }, 55 | selectUp() { 56 | if (this.selected !== 0) { 57 | this.selected -= 1; this.scrollIntoView(`item${this.selected}`) 58 | } 59 | }, 60 | selectDown() { 61 | if (this.selected !== this.filteredItems.length - 1) { 62 | this.selected += 1; this.scrollIntoView(`item${this.selected}`) 63 | } 64 | }, 65 | scrollIntoView(id) { 66 | document.getElementById(id).scrollIntoView({ block: 'nearest' }); 67 | }, 68 | fetchRaw(uri) { 69 | this.query = ''; 70 | this.selected = 0; 71 | fetch(uri) 72 | .then(response => response.text()) 73 | .then(text => { 74 | const PATTERN = /^(.*?):(.*?):\s*(?:\x1b\[0;36m(.*?)\x1b\[0m\s*)?(.*)$/ 75 | this.items = text.trim().split('\n').map(line => { 76 | const matches = PATTERN.exec(line); 77 | if (!matches) return null; 78 | const Filename = matches[1]; 79 | const Linenum = parseInt(matches[2], 10); 80 | const Colored = matches[3] || ''; 81 | const Content = matches[4]; 82 | const SearchStr = Colored ? `${Colored} ${Content}`.toLowerCase() : Content.toLowerCase(); 83 | return { Filename, Linenum, Colored, Content, SearchStr }; 84 | }).filter(Boolean); 85 | }); 86 | }, 87 | }, 88 | computed: { 89 | itemsLength() { 90 | return this.items.length; 91 | }, 92 | filteredItems() { 93 | this.selected = 0; 94 | const maxItems = 300; 95 | const queryWords = this.query.toLowerCase().split(' '); 96 | return !this.query 97 | ? this.items.slice(0, maxItems) 98 | : this.items.filter(item => (queryWords.every(queryWord => item.SearchStr.includes(queryWord)))).slice(0, maxItems); 99 | }, 100 | }, 101 | created() { 102 | this.preview = this.small ? false : this.preview; 103 | this.fetchRaw(this.uri); 104 | this.$nextTick(() => { this.query = this.initialQuery ? this.initialQuery : ''; this.$refs.queryInput.focus(); }); 105 | }, 106 | template: t 107 | } 108 | -------------------------------------------------------------------------------- /web/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Notesium Web 7 | 8 | 9 | 10 | 11 | 112 | 113 | 114 |
115 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /highlight.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | ansiHeading = "\033[1m" 14 | ansiBold = "\033[1m" 15 | ansiItalic = "\033[3m" 16 | ansiLink = "\033[34m" 17 | ansiCodeBlock = "\033[33m" 18 | ansiInlineCode = "\033[33m" 19 | ansiBlockQuote = "\033[36m" 20 | ansiListMarker = "\033[36m" 21 | ansiReset = "\033[0m" 22 | ) 23 | var ansiLineBg = func() string { 24 | switch os.Getenv("NOTESIUM_FINDER_THEME") { 25 | case "light": 26 | return "\033[48;5;7m" 27 | default: 28 | return "\033[40m" 29 | } 30 | }() 31 | var ( 32 | reBold = regexp.MustCompile(`\*\*(.*?)\*\*`) 33 | reBoldAlt = regexp.MustCompile(`__(.*?)__`) 34 | reItalic = regexp.MustCompile(`\*(.*?)\*`) 35 | reItalicAlt = regexp.MustCompile(`_(.*?)_`) 36 | reUnorderedList = regexp.MustCompile(`^(\s*[-+*]) `) 37 | reOrderedList = regexp.MustCompile(`^(\s*\d+\.) `) 38 | reInlineCode = regexp.MustCompile("`(.*?)`") 39 | reLinkPlain = regexp.MustCompile(`(?:https?://|www\.)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/[^\s]*)?`) 40 | reLinkMarkdown = regexp.MustCompile(`\[(.[^]]*?)\]\((.[^)]*?)\)`) 41 | reAnsi = regexp.MustCompile(`\x1b\[[0-9;]*m`) 42 | reReset = regexp.MustCompile(`\x1b\[0m`) 43 | ) 44 | 45 | func renderMarkdown(reader io.Reader, writer io.Writer, lineNumber int) { 46 | inCodeBlock := false 47 | scanner := bufio.NewScanner(reader) 48 | 49 | for lineIndex := 1; scanner.Scan(); lineIndex++ { 50 | line := scanner.Text() 51 | highlightedLine := highlightLine(line, &inCodeBlock) 52 | if lineNumber > 0 && lineNumber == lineIndex { 53 | highlightedLine = highlightLineWithBackground(highlightedLine) 54 | } 55 | fmt.Fprintln(writer, highlightedLine) 56 | } 57 | 58 | if err := scanner.Err(); err != nil { 59 | fmt.Fprintf(writer, "Error reading content: %v\n", err) 60 | } 61 | } 62 | 63 | func highlightLine(line string, inCodeBlock *bool) string { 64 | // Code blocks 65 | if strings.HasPrefix(line, "```") { 66 | *inCodeBlock = !*inCodeBlock 67 | return ansiCodeBlock + line + ansiReset 68 | } 69 | if *inCodeBlock { 70 | return ansiCodeBlock + line + ansiReset 71 | } 72 | 73 | // Headers 74 | if strings.HasPrefix(line, "#") { 75 | return ansiHeading + line + ansiReset 76 | } 77 | 78 | // Blockquotes 79 | if strings.HasPrefix(line, "> ") { 80 | return ansiBlockQuote + line + ansiReset 81 | } 82 | 83 | // Inline code 84 | matches := reInlineCode.FindAllStringSubmatchIndex(line, -1) 85 | if len(matches) > 0 { 86 | return highlightLineWithInlineCode(line, matches) 87 | } 88 | 89 | return highlightString(line) 90 | } 91 | 92 | func highlightString(line string) string { 93 | // Links 94 | line = highlightLink(line, reLinkMarkdown, ansiLink) 95 | line = highlightRegex(line, reLinkPlain, ansiLink, 0) 96 | 97 | // Bold (**text** or __text__) 98 | line = highlightRegex(line, reBold, ansiBold, 2) 99 | line = highlightRegex(line, reBoldAlt, ansiBold, 2) 100 | 101 | // Italic (*text* or _text_) 102 | line = highlightRegex(line, reItalic, ansiItalic, 1) 103 | line = highlightRegex(line, reItalicAlt, ansiItalic, 1) 104 | 105 | // List markers 106 | line = highlightRegex(line, reUnorderedList, ansiListMarker, 0) 107 | line = highlightRegex(line, reOrderedList, ansiListMarker, 0) 108 | 109 | return line 110 | } 111 | 112 | func highlightRegex(line string, re *regexp.Regexp, ansiCode string, markerLength int) string { 113 | return re.ReplaceAllStringFunc(line, func(match string) string { 114 | inner := match[markerLength : len(match)-markerLength] 115 | return ansiCode + inner + ansiReset 116 | }) 117 | } 118 | 119 | func highlightLink(line string, re *regexp.Regexp, ansiCode string) string { 120 | return re.ReplaceAllStringFunc(line, func(match string) string { 121 | matches := re.FindStringSubmatch(match) 122 | if len(matches) >= 2 { 123 | title := matches[1] 124 | return ansiCode + title + ansiReset 125 | } 126 | return match 127 | }) 128 | } 129 | 130 | func highlightLineWithInlineCode(line string, matches [][]int) string { 131 | var builder strings.Builder 132 | prevIndex := 0 133 | 134 | for _, match := range matches { 135 | start, end := match[0], match[1] 136 | groupStart, groupEnd := match[2], match[3] 137 | 138 | // Handle text before inline code 139 | if start > prevIndex { 140 | builder.WriteString(highlightString(line[prevIndex:start])) 141 | } 142 | 143 | builder.WriteString(ansiInlineCode) 144 | builder.WriteString(line[groupStart:groupEnd]) 145 | builder.WriteString(ansiReset) 146 | 147 | prevIndex = end 148 | } 149 | 150 | // Handle text after inline code 151 | if prevIndex < len(line) { 152 | builder.WriteString(highlightString(line[prevIndex:])) 153 | } 154 | 155 | return builder.String() 156 | } 157 | 158 | 159 | func highlightLineWithBackground(highlightedLine string) string { 160 | // apply bg after resets to handle segments 161 | highlightedLine = reReset.ReplaceAllStringFunc(highlightedLine, func(reset string) string { 162 | return reset + ansiLineBg 163 | }) 164 | 165 | // apply padding 166 | termWidth := 79 167 | visibleChars := len(reAnsi.ReplaceAllString(highlightedLine, "")) 168 | requiredPadding := termWidth - visibleChars 169 | if requiredPadding > 0 { 170 | padding := strings.Repeat(" ", requiredPadding) 171 | highlightedLine += ansiLineBg + padding 172 | } 173 | 174 | return ansiLineBg + highlightedLine + ansiReset 175 | } 176 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestTokenizeFilterQuery(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input string 12 | expected []string 13 | }{ 14 | { 15 | name: "No quotes, single tokens only", 16 | input: "book physics !math", 17 | expected: []string{"book", "physics", "!math"}, 18 | }, 19 | { 20 | name: "Double-quoted phrase", 21 | input: `"earth science" physics`, 22 | expected: []string{"earth science", "physics"}, 23 | }, 24 | { 25 | name: "Single-quoted phrase", 26 | input: `book 'social science' biology`, 27 | expected: []string{"book", "social science", "biology"}, 28 | }, 29 | { 30 | name: "Mixed single and double quotes", 31 | input: `"environmental science" 'earth science' math`, 32 | expected: []string{"environmental science", "earth science", "math"}, 33 | }, 34 | { 35 | name: "Unclosed quote (double)", 36 | input: `"science math`, 37 | // The entire remainder after the first quote goes into the same token 38 | // This behavior depends on your parser design; you might decide to handle or error out. 39 | expected: []string{"science math"}, 40 | }, 41 | { 42 | name: "Unclosed quote (single)", 43 | input: `'science math`, 44 | expected: []string{"science math"}, 45 | }, 46 | { 47 | name: "Multiple separate phrases with OR inside", 48 | input: `"earth science"|chemistry !biology`, 49 | // This gets tokenized into 3 tokens: 50 | // 1. earth science|chemistry 51 | // 2. !biology 52 | // 53 | // Because there's no space between "earth science"|chemistry, 54 | // they remain in one token (the user might intend that). 55 | expected: []string{"earth science|chemistry", "!biology"}, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | got := tokenizeFilterQuery(tt.input) 62 | if !reflect.DeepEqual(got, tt.expected) { 63 | t.Errorf("got %v, want %v", got, tt.expected) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestEvaluateFilterQuery(t *testing.T) { 70 | tests := []struct { 71 | name string 72 | query string 73 | input string 74 | expectedMatch bool 75 | }{ 76 | // ---------------------------------- 77 | // Basic AND (space) 78 | { 79 | name: "Simple AND matches", 80 | query: "book chemistry", 81 | input: "I found a physics book today", 82 | expectedMatch: false, 83 | }, 84 | { 85 | name: "Simple AND true", 86 | query: "book physics", 87 | input: "I found a physics book today", 88 | expectedMatch: true, 89 | }, 90 | 91 | // ---------------------------------- 92 | // OR logic (|) 93 | { 94 | name: "OR logic - one term found", 95 | query: "science|math", 96 | input: "I enjoy reading about science topics", 97 | expectedMatch: true, 98 | }, 99 | { 100 | name: "OR logic - no term found", 101 | query: "apple|banana", 102 | input: "I love oranges", 103 | expectedMatch: false, 104 | }, 105 | 106 | // ---------------------------------- 107 | // NOT logic (!) 108 | { 109 | name: "NOT logic - excluded term present => false", 110 | query: "book !math", 111 | input: "I have a math book", 112 | expectedMatch: false, 113 | }, 114 | { 115 | name: "NOT logic - excluded term absent => true", 116 | query: "book !math", 117 | input: "I have a science book", 118 | expectedMatch: true, 119 | }, 120 | 121 | // ---------------------------------- 122 | // Phrase testing (quotes) 123 | { 124 | name: "Double-quoted phrase present", 125 | query: `"earth science"`, 126 | input: "My earth science teacher is great", 127 | expectedMatch: true, 128 | }, 129 | { 130 | name: "Double-quoted phrase absent", 131 | query: `"earth science"`, 132 | input: "I love rocket science", 133 | expectedMatch: false, 134 | }, 135 | { 136 | name: "Single-quoted phrase present", 137 | query: `book 'social science'`, 138 | input: "I have a social science book for class", 139 | // We want both "book" AND "social science" => expect true 140 | expectedMatch: true, 141 | }, 142 | { 143 | name: "Single-quoted phrase absent", 144 | query: `book 'social science'`, 145 | input: "I have a math book", 146 | // Missing "social science" 147 | expectedMatch: false, 148 | }, 149 | 150 | // ---------------------------------- 151 | // Combined logic with OR + phrase 152 | { 153 | name: "Phrase + OR logic pass", 154 | query: `"earth science"|biology`, 155 | input: "I am studying biology this semester", 156 | // OR logic => "earth science" or "biology" 157 | expectedMatch: true, 158 | }, 159 | { 160 | name: "Phrase + OR logic fail", 161 | query: `"earth science"|biology`, 162 | input: "I am studying math and physics", 163 | expectedMatch: false, 164 | }, 165 | } 166 | 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | got, err := evaluateFilterQuery(tt.query, tt.input) 170 | if err != nil { 171 | t.Errorf("evaluateFilterQuery returned error: %v", err) 172 | } 173 | 174 | if got != tt.expectedMatch { 175 | t.Errorf("query=%q input=%q => got %v, want %v", 176 | tt.query, tt.input, got, tt.expectedMatch) 177 | } 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/raw.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | _curl() { curl -qs "http://localhost:8881/${1}" ; } 6 | 7 | _os_arch() { 8 | uname -sm | tr A-Z a-z | sed 's/ /\//;s/x86_64/amd64/;s/aarch64/arm64/' 9 | } 10 | 11 | setup_file() { 12 | command -v curl >/dev/null 13 | export TZ="UTC" 14 | export NOTESIUM_DIR="$BATS_TEST_DIRNAME/fixtures" 15 | export EXPECTED_PLATFORM="$(_os_arch)" 16 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 17 | [ "$(pgrep -x notesium)" == "" ] 18 | } 19 | 20 | @test "api/raw: start with custom port and stop-on-idle" { 21 | run notesium web --port=8881 --stop-on-idle & 22 | echo "$output" 23 | } 24 | 25 | @test "api/raw: new default" { 26 | run _curl 'api/raw/new' 27 | echo "$output" 28 | [ $status -eq 0 ] 29 | [ "$(dirname $output)" == "$BATS_TEST_DIRNAME/fixtures" ] 30 | epoch="$(printf '%d' 0x$(basename --suffix=.md $output))" 31 | [ "$epoch" -gt "$(date -d "-5 seconds" +%s)" ] 32 | [ "$epoch" -lt "$(date -d "+5 seconds" +%s)" ] 33 | } 34 | 35 | @test "api/raw: new verbose with custom ctime" { 36 | run _curl 'api/raw/new?verbose=true&ctime=2023-01-16T05:05:00' 37 | echo "$output" 38 | [ "${#lines[@]}" -eq 5 ] 39 | [ "${lines[1]}" == "filename:63c4dafc.md" ] 40 | [ "${lines[2]}" == "epoch:1673845500" ] 41 | [ "${lines[3]}" == "ctime:2023-01-16T05:05:00+00:00" ] 42 | [ "${lines[4]}" == "exists:false" ] 43 | } 44 | 45 | @test "api/raw: list default" { 46 | run _curl 'api/raw/list' 47 | echo "$output" 48 | [ $status -eq 0 ] 49 | [ "${#lines[@]}" -eq 8 ] 50 | assert_line "6421460b.md:1: book" 51 | assert_line "642146c7.md:1: physicist" 52 | assert_line "64214930.md:1: quantum mechanics" 53 | assert_line "64214a1d.md:1: richard feynman" 54 | assert_line "642176a6.md:1: lorem ipsum" 55 | assert_line "64217712.md:1: empty note" 56 | assert_line "64218087.md:1: surely you're joking mr. feynman" 57 | assert_line "64218088.md:1: albert einstein" 58 | } 59 | 60 | @test "api/raw: list sort alphabetically" { 61 | run _curl 'api/raw/list?sort=alpha' 62 | echo "$output" 63 | [ $status -eq 0 ] 64 | [ "${lines[0]}" == "64218088.md:1: albert einstein" ] 65 | [ "${lines[1]}" == "6421460b.md:1: book" ] 66 | [ "${lines[2]}" == "64217712.md:1: empty note" ] 67 | [ "${lines[3]}" == "642176a6.md:1: lorem ipsum" ] 68 | [ "${lines[4]}" == "642146c7.md:1: physicist" ] 69 | [ "${lines[5]}" == "64214930.md:1: quantum mechanics" ] 70 | [ "${lines[6]}" == "64214a1d.md:1: richard feynman" ] 71 | [ "${lines[7]}" == "64218087.md:1: surely you're joking mr. feynman" ] 72 | } 73 | 74 | @test "api/raw: links default without filename" { 75 | run _curl 'api/raw/links' 76 | echo "$output" 77 | [ $status -eq 0 ] 78 | [ "${#lines[@]}" -eq 7 ] 79 | assert_line "64218088.md:3: albert einstein → physicist" 80 | assert_line "64218088.md:7: albert einstein → quantum mechanics" 81 | assert_line "64214a1d.md:3: richard feynman → physicist" 82 | assert_line "64214a1d.md:5: richard feynman → quantum mechanics" 83 | assert_line "64218087.md:3: surely you're joking mr. feynman → book" 84 | assert_line "64218087.md:3: surely you're joking mr. feynman → richard feynman" 85 | assert_line "64218087.md:3: surely you're joking mr. feynman → 12345678.md" 86 | } 87 | 88 | @test "api/raw: links default with filename" { 89 | run _curl 'api/raw/links?filename=64214a1d.md' 90 | echo "$output" 91 | [ $status -eq 0 ] 92 | [ "${#lines[@]}" -eq 3 ] 93 | assert_line "642146c7.md:1: outgoing physicist" 94 | assert_line "64214930.md:1: outgoing quantum mechanics" 95 | assert_line "64218087.md:3: incoming surely you're joking mr. feynman" 96 | } 97 | 98 | @test "api/raw: lines default" { 99 | run _curl 'api/raw/lines' 100 | echo "$output" 101 | [ $status -eq 0 ] 102 | assert_line "6421460b.md:1: # book" 103 | assert_line "642146c7.md:1: # physicist" 104 | assert_line "64214930.md:3: a fundamental theory in physics that provides a description of the" 105 | assert_line "64214930.md:5: particles." 106 | } 107 | 108 | @test "api/raw: stats default" { 109 | run _curl 'api/raw/stats' 110 | echo "$output" 111 | [ $status -eq 0 ] 112 | [ "${lines[0]}" == "notes 8" ] 113 | [ "${lines[1]}" == "labels 2" ] 114 | [ "${lines[2]}" == "orphans 2" ] 115 | [ "${lines[3]}" == "links 7" ] 116 | [ "${lines[4]}" == "dangling 1" ] 117 | [ "${lines[5]}" == "lines 28" ] 118 | [ "${lines[6]}" == "words 213" ] 119 | [ "${lines[7]}" == "chars 1396" ] 120 | } 121 | 122 | @test "api/raw: version verbose sniff test" { 123 | run _curl 'api/raw/version?verbose=true' 124 | echo "$output" 125 | [ $status -eq 0 ] 126 | [[ "${lines[0]}" =~ "version:" ]] 127 | [[ "${lines[1]}" =~ "gitversion:v" ]] 128 | [[ "${lines[2]}" =~ "buildtime:" ]] 129 | [[ "${lines[3]}" =~ "platform:$EXPECTED_PLATFORM" ]] 130 | } 131 | 132 | @test "api/raw: no command specified error" { 133 | run _curl 'api/raw/' 134 | echo "$output" 135 | [ $status -eq 0 ] 136 | [[ "${lines[0]}" =~ 'no command specified' ]] 137 | } 138 | 139 | @test "api/raw: unrecognized command error" { 140 | run _curl 'api/raw/foo' 141 | echo "$output" 142 | [ $status -eq 0 ] 143 | [[ "${lines[0]}" =~ 'unrecognized command: foo' ]] 144 | } 145 | 146 | @test "api/raw: stop by sending terminate signal" { 147 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 148 | run pgrep -x notesium 149 | echo "$output" 150 | echo "could not get pid" 151 | [ $status -eq 0 ] 152 | 153 | run kill "$(pgrep -x notesium)" 154 | echo "$output" 155 | [ $status -eq 0 ] 156 | 157 | run pgrep -x notesium 158 | echo "$output" 159 | [ $status -eq 1 ] 160 | } 161 | 162 | -------------------------------------------------------------------------------- /vim/doc/notesium.txt: -------------------------------------------------------------------------------- 1 | notesium.txt Notesium Vim Plugin Last change: May 25 2025 2 | TABLE OF CONTENTS *notesium* *notesium-toc* 3 | ============================================================================== 4 | 5 | Notesium |notesium| 6 | Setup |notesium-setup| 7 | Configuration |notesium-config| 8 | Commands |notesium-commands| 9 | Mappings |notesium-mappings| 10 | Finder |notesium-finder| 11 | License |notesium-license| 12 | 13 | NOTESIUM *notesium* 14 | ============================================================================== 15 | 16 | Notesium - A simple yet powerful system for networked thought. 17 | > 18 | Writing does not make intellectual endeavours easier, it makes them 19 | possible. Deepen understanding, insight, and allow for structure to emerge 20 | organically by linking notes. 21 | < 22 | See the {Website}{1} and {GitHub}{2} repository for more details. 23 | 24 | {1} https://www.notesium.com 25 | {2} https://github.com/alonswartz/notesium 26 | 27 | SETUP *notesium-setup* 28 | ============================================================================== 29 | 30 | The Notesium Vim plugin provides an interface for interacting with Notesium 31 | from within Vim/NeoVim, and therefore requires the `notesium` binary to be 32 | installed. 33 | 34 | To install the plugin, add the repository to your plugin manager and 35 | point its runtime path to the `'vim'` directory. For example: 36 | > 37 | " init.vim or .vimrc 38 | Plug 'alonswartz/notesium', { 'rtp': 'vim' } 39 | 40 | -- init.lua 41 | Plug('alonswartz/notesium', { ['rtp'] = 'vim' }) 42 | < 43 | Note: The plugin depends on notesium 0.6.4 or above. 44 | 45 | CONFIGURATION *notesium-config* 46 | ============================================================================== 47 | 48 | `g:notesium_bin` Binary name or path `notesium` 49 | `g:notesium_mappings` Enable(1) or disable(0) mappings `1` 50 | `g:notesium_weekstart` First day of the week `monday` 51 | `g:notesium_window` Finder Default `{'width': 0.85, 'height': 0.85}` 52 | `g:notesium_window_small` Finder InsertLink `{'width': 0.50, 'height': 0.50}` 53 | 54 | Note: These settings should be set prior to the plugin being sourced. 55 | 56 | COMMANDS *notesium-commands* 57 | ============================================================================== 58 | 59 | `:NotesiumNew` Open new `note` for editing 60 | `:NotesiumDaily [YYYY-MM-DD]` Open new or existing daily `note` 61 | `:NotesiumWeekly [YYYY-MM-DD]` Open new or existing weekly `note` 62 | `:NotesiumList [LIST_OPTS]` Open finder: list of notes 63 | `:NotesiumLines [LINES_OPTS]` Open finder: lines of all notes 64 | `:NotesiumLinks [LINKS_OPTS]` Open finder: links of all notes 65 | `:NotesiumLinks! [LINKS_OPTS]` Open finder: links of the active `note` 66 | `:NotesiumInsertLink [LIST_OPTS]` Open finder: insert selection as markdown link 67 | `:NotesiumWeb [WEB_OPTS]` Start web server, open browser (stop on idle) 68 | `:NotesiumDeleteNote` Delete current note (with verify and confirm) 69 | 70 | Note: `NotesiumWeekly` depends on `g:notesium_weekstart`. 71 | 72 | MAPPINGS *notesium-mappings* 73 | ============================================================================== 74 | 75 | INSERT MODE 76 | 77 | `[[` Opens `note` list, insert selection as markdown formatted link 78 | 79 | NORMAL MODE 80 | 81 | `nn` Opens new `note` for editing 82 | `nd` Opens new or existing daily `note` 83 | `nw` Opens new or existing weekly `note` 84 | `nl` List with prefixed label, sorted alphabetically; mtime if journal 85 | `nm` List with prefixed date modified, sorted by mtime 86 | `nc` List with prefixed date created `(YYYY/WeekXX)`, sorted by ctime 87 | `nk` List all links related to active `note` (or all if none) 88 | `ns` Full text search with prefixed `note` title 89 | `nW` Opens browser with embedded web/app (auto stop webserver on idle) 90 | 91 | Note: The mappings can be enabled/disabled via `g:notesium_mappings`. 92 | 93 | FINDER *notesium-finder* 94 | ============================================================================== 95 | 96 | KEYBINDINGS 97 | 98 | `C-j` Select next entry (down) 99 | `C-k` Select previous entry (up) 100 | `C-/` Toggle preview 101 | `Enter` Submit selected entry 102 | `Esc` Dismiss finder 103 | 104 | SEARCH SYNTAX 105 | 106 | `word` Exact-match Items that include `word` 107 | `^word` Prefix exact-match Items that start with `word` 108 | `word$` Suffix exact-match Items that end with `word` 109 | `!word` Inverse exact-match Items that do not include `word` 110 | `!^word` Inverse prefix exact-match Items that do not start with `word` 111 | `!word$` Inverse suffix exact-match Items that do not end with `word` 112 | `foo bar` Multiple exact match (AND) Items that include both `foo` AND `bar` 113 | `foo | bar` Multiple exact match (OR) Items that include either `foo` OR `bar` 114 | `'sbtrkt` Fuzzy-match Items that fuzzy match `sbtrkt` 115 | 116 | LICENSE *notesium-license* 117 | ============================================================================== 118 | 119 | The MIT License (MIT) 120 | 121 | Copyright (c) 2023-2025 Alon Swartz 122 | 123 | ============================================================================== 124 | vim:tw=78:sw=2:ts=2:ft=help:nowrap: 125 | -------------------------------------------------------------------------------- /contrib/ctimehex.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Prior to v0.2.0, 8 RANDOM hexidecimal digits were used for filenames. 4 | # Since v0.2.0, the UNIX epoch time is used, and encoded in hexidecimal. 5 | # 6 | # To aid in conversion, this script can be used to rename all files 7 | # to the new format as well as update all links. The ctime used will 8 | # be that of the first git commit or mtime of the file, whichever 9 | # is eariler. 10 | 11 | set -e 12 | 13 | fatal() { echo "Fatal: $*" 1>&2; exit 1; } 14 | 15 | usage() { 16 | cat< "$CTIMEHEX_LINKS1" 103 | 104 | for line in $(cat "$CTIMEHEX_LIST"); do 105 | fname_old="$(echo "$line" | awk -F ":" '{print $1}')" 106 | fname_new="$(echo "$line" | awk -F ":" '{print $2}')" 107 | echo "* updating $fname_old to $fname_new" 108 | sed -i "s|]($fname_old)|]($fname_new)|g" *.md 109 | git mv "$fname_old" "$fname_new" 110 | done 111 | 112 | # restore modification time? 113 | # for line in $(cat "$CTIMEHEX_LIST"); do 114 | # fname_new="$(echo "$line" | awk -F ":" '{print $2}')" 115 | # epoch_mod="$(echo "$line" | awk -F ":" '{print $4}')" 116 | # mtime_mod="$(date -d "@$epoch_mod" "+%Y%m%d%H%M.%S")" 117 | # touch -m -t "$mtime_mod" "$fname_new" 118 | # git add "$fname_new" 119 | # done 120 | 121 | echo "* snapshot: $CTIMEHEX_LINKS1" 122 | NOTESIUM_DIR=. notesium links | cut -d: -f2- > "$CTIMEHEX_LINKS2" 123 | 124 | echo "* snapshot: comparison diff" 125 | if [ "$(diff "$CTIMEHEX_LINKS1" "$CTIMEHEX_LINKS2")" ]; then 126 | echo "* Error: post conversion test failed, files differ:" 1>&2 127 | echo " - $CTIMEHEX_LINKS1" 1>&2 128 | echo " - $CTIMEHEX_LINKS2" 1>&2 129 | return 1 130 | fi 131 | echo "* conversion complete. please verify, commit changes, and remove:" 132 | echo " - $CTIMEHEX_LIST" 133 | echo " - $CTIMEHEX_LINKS1" 134 | echo " - $CTIMEHEX_LINKS2" 135 | return 0 136 | } 137 | 138 | main() { 139 | case $1 in ""|-h|--help|help) usage;; esac 140 | 141 | [ "$NOTESIUM_DIR" ] || fatal "NOTESIUM_DIR not set in environment" 142 | [ -d "$NOTESIUM_DIR" ] || fatal "$NOTESIUM_DIR does not exist" 143 | NOTESIUM_DIR="$(realpath "$NOTESIUM_DIR")" 144 | CTIMEHEX_LIST="$NOTESIUM_DIR/ctimehex-list.txt" 145 | CTIMEHEX_LINKS1="$NOTESIUM_DIR/ctimehex-links1.txt" 146 | CTIMEHEX_LINKS2="$NOTESIUM_DIR/ctimehex-links2.txt" 147 | cd "$NOTESIUM_DIR" 148 | 149 | command -v git >/dev/null || fatal "git not found" 150 | command -v notesium >/dev/null || fatal "notesium not found" 151 | git rev-parse --is-inside-work-tree >/dev/null 152 | 153 | case $1 in 154 | 0-dates) ctimehex_dates;; 155 | 1-list) ctimehex_list;; 156 | 2-convert) ctimehex_convert;; 157 | *) fatal "unrecognized command: $1";; 158 | esac 159 | } 160 | 161 | main "$@" 162 | -------------------------------------------------------------------------------- /web/app/datepicker.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 | 4 |
5 |

6 |
7 | 11 | 14 | 18 |
19 |
20 | 21 |
22 |
24 |
25 | 26 |
27 |
32 | 35 | 36 | 37 | 38 | 39 |
40 |
41 | 42 |
43 | ` 44 | 45 | import Icon from './icon.js' 46 | import { formatDate } from './dateutils.js'; 47 | export default { 48 | props: { 49 | dottedDates: { type: Object, default: {} }, 50 | }, 51 | emits: ['date-selected', 'date-dblclick'], 52 | components: { Icon }, 53 | data() { 54 | return { 55 | today: null, 56 | selectedDate: null, 57 | displayedMonth: null, 58 | } 59 | }, 60 | methods: { 61 | getDotForType(dateStr, type) { 62 | const noteTypes = this.dottedDates[dateStr]; 63 | if (noteTypes && noteTypes.includes(type)) return '•'; 64 | return ''; 65 | }, 66 | getCalendarDays(year, month) { 67 | const startDate = new Date(year, month, 1); 68 | const endDate = new Date(year, month + 1, 0); // Last day of the month 69 | const days = []; 70 | 71 | // Previous month days 72 | let startDayOfWeek = startDate.getDay() - this.$notesiumState.startOfWeek; 73 | if (startDayOfWeek < 0) startDayOfWeek += 7; 74 | for (let i = startDayOfWeek; i > 0; i--) { 75 | const date = new Date(year, month, 1 - i); 76 | days.push({ 77 | date: formatDate(date, '%Y-%m-%d'), 78 | day: date.getDate(), 79 | }); 80 | } 81 | 82 | // Current month days 83 | for (let day = 1; day <= endDate.getDate(); day++) { 84 | const date = new Date(year, month, day); 85 | const dateStr = formatDate(date, '%Y-%m-%d'); 86 | days.push({ 87 | date: dateStr, 88 | day: day, 89 | isCurrentMonth: true, 90 | isToday: dateStr === this.today, 91 | }); 92 | } 93 | 94 | // Next month days to complete the week 95 | let endDayOfWeek = endDate.getDay(); 96 | let daysToAdd = 6 - ((endDayOfWeek - this.$notesiumState.startOfWeek + 7) % 7); 97 | if ((days.length + daysToAdd) == 35) daysToAdd += 7; 98 | for (let i = 1; i <= daysToAdd; i++) { 99 | const date = new Date(year, month + 1, i); 100 | days.push({ 101 | date: formatDate(date, '%Y-%m-%d'), 102 | day: date.getDate(), 103 | }); 104 | } 105 | 106 | return days; 107 | }, 108 | setSelectedDate(dateStr) { 109 | this.selectedDate = dateStr; 110 | this.$emit('date-selected', this.selectedDate); 111 | const dateParts = this.selectedDate.split('-'); 112 | const selectedYear = parseInt(dateParts[0], 10); 113 | const selectedMonth = parseInt(dateParts[1], 10); 114 | const displayedMonthDate = new Date(this.displayedMonth); 115 | if (selectedYear !== displayedMonthDate.getFullYear() || selectedMonth !== displayedMonthDate.getMonth() + 1) { 116 | this.displayedMonth = new Date(selectedYear, selectedMonth - 1, 1); 117 | } 118 | }, 119 | changeMonth(increment) { 120 | this.displayedMonth = new Date(this.displayedMonth.getFullYear(), this.displayedMonth.getMonth() + increment, 1); 121 | }, 122 | }, 123 | computed: { 124 | formattedMonthYear() { 125 | return this.displayedMonth.toLocaleString('default', { month: 'short', year: 'numeric' }); 126 | }, 127 | displayedMonthDates() { 128 | const year = this.displayedMonth.getFullYear(); 129 | const month = this.displayedMonth.getMonth(); // getMonth is 0-indexed 130 | return this.getCalendarDays(year, month) 131 | }, 132 | sortedDaysOfWeek() { 133 | const daysOfWeek = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; 134 | return [...daysOfWeek.slice(this.$notesiumState.startOfWeek), ...daysOfWeek.slice(0, this.$notesiumState.startOfWeek)]; 135 | }, 136 | }, 137 | created() { 138 | this.displayedMonth = new Date(); 139 | this.today = formatDate(this.displayedMonth, '%Y-%m-%d'); 140 | }, 141 | mounted() { 142 | this.setSelectedDate(this.today); 143 | }, 144 | template: t 145 | } 146 | -------------------------------------------------------------------------------- /tests/write.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load helpers.sh 4 | 5 | URL="http://localhost:8881" 6 | _get() { curl -qs ${URL}/${1} ; } 7 | _get_jq() { curl -qs ${URL}/${1} | jq -r "${2}" ; } 8 | _post() { curl -qs -X POST -d "${2}" ${URL}/${1} ; } 9 | _post_jq() { curl -qs -X POST -d "${2}" ${URL}/${1} | jq -r "${3}" ; } 10 | _patch() { curl -qs -X PATCH -d "${2}" ${URL}/${1} ; } 11 | _patch_jq() { curl -qs -X PATCH -d "${2}" ${URL}/${1} | jq -r "${3}" ; } 12 | 13 | _set_deterministic_mtimes() { 14 | touch -m -t 202301250505 "/tmp/notesium-test-corpus/64218088.md" 15 | touch -m -t 202301240505 "/tmp/notesium-test-corpus/64217712.md" 16 | touch -m -t 202301240504 "/tmp/notesium-test-corpus/642146c7.md" 17 | touch -m -t 202301220505 "/tmp/notesium-test-corpus/642176a6.md" 18 | touch -m -t 202301220504 "/tmp/notesium-test-corpus/64214930.md" 19 | touch -m -t 202301180505 "/tmp/notesium-test-corpus/64218087.md" 20 | touch -m -t 202301160505 "/tmp/notesium-test-corpus/64214a1d.md" 21 | touch -m -t 202301130505 "/tmp/notesium-test-corpus/6421460b.md" 22 | } 23 | 24 | setup_file() { 25 | command -v jq >/dev/null 26 | command -v curl >/dev/null 27 | [ "$(pgrep -x notesium)" == "" ] 28 | [ -e "/tmp/notesium-test-corpus" ] && exit 1 29 | run mkdir /tmp/notesium-test-corpus 30 | run cp $BATS_TEST_DIRNAME/fixtures/*.md /tmp/notesium-test-corpus/ 31 | run _set_deterministic_mtimes 32 | export NOTESIUM_DIR="/tmp/notesium-test-corpus" 33 | export PATH="$(realpath $BATS_TEST_DIRNAME/../):$PATH" 34 | } 35 | 36 | teardown_file() { 37 | if [ "$PAUSE" ]; then 38 | echo "# NOTESIUM_DIR=$NOTESIUM_DIR" >&3 39 | echo "# PAUSED: Press enter to continue with teardown... " >&3 40 | run read -p "paused: " choice 41 | fi 42 | run rm /tmp/notesium-test-corpus/*.md 43 | run rmdir /tmp/notesium-test-corpus 44 | } 45 | 46 | @test "write: start with custom port, stop-on-idle, NOT writable" { 47 | run notesium web --port=8881 --stop-on-idle & 48 | echo "$output" 49 | } 50 | 51 | @test "write: change note should fail" { 52 | run _patch_jq 'api/notes/64214a1d.md' '{"Content": "# mr. richard feynman"}' '.Error' 53 | echo "$output" 54 | [ "${lines[0]}" == "NOTESIUM_DIR is set to read-only mode" ] 55 | } 56 | 57 | @test "write: stop NOT writable by sending terminate signal" { 58 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 59 | run pgrep -x notesium 60 | echo "$output" 61 | echo "could not get pid" 62 | [ $status -eq 0 ] 63 | 64 | run kill "$(pgrep -x notesium)" 65 | echo "$output" 66 | [ $status -eq 0 ] 67 | 68 | run pgrep -x notesium 69 | echo "$output" 70 | [ $status -eq 1 ] 71 | } 72 | 73 | @test "write: start with custom port, stop-on-idle, writable" { 74 | run notesium web --port=8881 --stop-on-idle --writable & 75 | echo "$output" 76 | } 77 | 78 | @test "write: verify incoming links pre change" { 79 | run _get_jq 'api/notes/642146c7.md' '.IncomingLinks | length' 80 | echo "$output" 81 | [ $status -eq 0 ] 82 | [ "${lines[0]}" == "2" ] 83 | } 84 | 85 | @test "write: change note" { 86 | run _get_jq 'api/notes/64214a1d.md' '.Mtime' 87 | LastMtime="$output" 88 | echo "$output" 89 | [ $status -eq 0 ] 90 | 91 | run _patch_jq 'api/notes/64214a1d.md' '{"Content": "# mr. richard feynman", "LastMtime": "'"$LastMtime"'"}' '.Title' 92 | echo "$output" 93 | [ $status -eq 0 ] 94 | [ "${lines[0]}" == "mr. richard feynman" ] 95 | } 96 | 97 | @test "write: verify note changed in cache" { 98 | run _get_jq 'api/notes/64214a1d.md' '.Content' 99 | echo "$output" 100 | [ $status -eq 0 ] 101 | [ "${lines[0]}" == "# mr. richard feynman" ] 102 | } 103 | 104 | @test "write: verify note changed on disk" { 105 | run cat $NOTESIUM_DIR/64214a1d.md 106 | echo "$output" 107 | [ $status -eq 0 ] 108 | [ "${lines[0]}" == "# mr. richard feynman" ] 109 | } 110 | 111 | @test "write: verify incoming links post change" { 112 | run _get_jq 'api/notes/642146c7.md' '.IncomingLinks | length' 113 | echo "$output" 114 | [ $status -eq 0 ] 115 | [ "${lines[0]}" == "1" ] 116 | } 117 | 118 | @test "write: change note with incorrect mtime" { 119 | run _patch_jq 'api/notes/64214a1d.md' '{"Content": "# mr. richard feynman", "LastMtime": "2023-01-16T05:05:00+02:00"}' '.Error' 120 | echo "$output" 121 | [ "${lines[0]}" == "Refusing to overwrite. File changed on disk." ] 122 | } 123 | 124 | @test "write: change note without specifying params" { 125 | run _patch_jq 'api/notes/64214a1d.md' '{}' '.Error' 126 | echo "$output" 127 | [ "${lines[0]}" == "Content field is required" ] 128 | } 129 | 130 | @test "write: change note that does not exist" { 131 | run _patch_jq 'api/notes/aaaaaaaa.md' '{"Content": "# test", "LastMtime": "2023-01-16T05:05:00+02:00"}' '.Error' 132 | echo "$output" 133 | [ "${lines[0]}" == "Note not found" ] 134 | } 135 | 136 | @test "write: new note" { 137 | run _post_jq 'api/notes/' '{"Content": "# new note", "Ctime": "2023-11-30T14:20:00+02:00"}' '.Title' 138 | echo "$output" 139 | [ $status -eq 0 ] 140 | [ "${lines[0]}" == "new note" ] 141 | } 142 | 143 | @test "write: new note with conflicting ctime" { 144 | run _post_jq 'api/notes/' '{"Content": "# new note", "Ctime": "2023-11-30T14:20:00+02:00"}' '.Error' 145 | echo "$output" 146 | [ "${lines[0]}" == "File already exists" ] 147 | } 148 | 149 | @test "write: new note without specifying ctime" { 150 | run _post_jq 'api/notes/' '{"Content": "# new note"}' '.Error' 151 | echo "$output" 152 | [ "${lines[0]}" == "Ctime field is required" ] 153 | } 154 | 155 | @test "write: new note with malformed ctime" { 156 | run _post_jq 'api/notes/' '{"Content": "# new note", "Ctime": "2023-11-30"}' '.Code' 157 | echo "$output" 158 | [ "${lines[0]}" == "400" ] 159 | } 160 | 161 | @test "write: new note with filename" { 162 | run _post_jq 'api/notes/aaaaaaaa.md' '{"Content": "# new note", "Ctime": "2023-11-30T14:20:00+02:00"}' '.Error' 163 | echo "$output" 164 | [ "${lines[0]}" == "Filename should not be specified" ] 165 | } 166 | 167 | @test "write: stop by sending terminate signal" { 168 | # force stop otherwise bats will block until timeout (bats-core/issues/205) 169 | run pgrep -x notesium 170 | echo "$output" 171 | echo "could not get pid" 172 | [ $status -eq 0 ] 173 | 174 | run kill "$(pgrep -x notesium)" 175 | echo "$output" 176 | [ $status -eq 0 ] 177 | 178 | run pgrep -x notesium 179 | echo "$output" 180 | [ $status -eq 1 ] 181 | } 182 | 183 | -------------------------------------------------------------------------------- /web/app/note-sidebar.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 3 | 101 | 102 | ` 103 | 104 | import Pane from './pane.js' 105 | import Icon from './icon.js' 106 | import LinkTree from './link-tree.js' 107 | import { formatDate } from './dateutils.js'; 108 | export default { 109 | components: { Pane, Icon, LinkTree }, 110 | props: ['note'], 111 | emits: ['note-open', 'note-save', 'note-delete', 'finder-open'], 112 | methods: { 113 | formattedDate(dateStr) { 114 | if (!dateStr) return ''; 115 | const date = new Date(dateStr); 116 | return formatDate(date, '%b %d %Y at %H:%M'); 117 | }, 118 | }, 119 | computed: { 120 | sortedIncomingLinks() { 121 | return this.note.IncomingLinks?.sort((a, b) => a.Title.localeCompare(b.Title)) || []; 122 | }, 123 | countIncomingLinks() { 124 | return this.note.IncomingLinks?.length || 0; 125 | }, 126 | existingOutgoingLinks() { 127 | return this.note.OutgoingLinks?.filter(l => l.Title !== '') || []; 128 | }, 129 | danglingOutgoingLinks() { 130 | return this.note.OutgoingLinks?.filter(l => l.Title == '') || []; 131 | }, 132 | countOutgoingLinks() { 133 | return this.note.OutgoingLinks?.length || 0; 134 | }, 135 | }, 136 | template: t 137 | } 138 | -------------------------------------------------------------------------------- /web/app/graph-panel.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 | 3 |
4 | 5 |
6 |
7 | 8 |
9 |
10 | graph view 11 | 12 | 13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | 24 | 25 | 26 |
27 |
28 | 29 |
30 |
31 |
32 | 37 | 39 | 40 | 41 |
42 |
43 |
44 | 45 | display 46 | 47 | 48 |
    49 |
  • 51 | 52 | 53 |
  • 54 |
55 |
56 |
57 | 58 | forces 59 | 60 | 61 |
    62 |
  • 64 |
    65 | 66 | 67 |
    68 | 69 |
  • 70 |
71 |
72 |
73 |
74 |
75 | 76 | 84 | 85 |
86 |
87 | ` 88 | 89 | import Pane from './pane.js' 90 | import Icon from './icon.js' 91 | import GraphD3 from './graph-d3.js' 92 | export default { 93 | components: { Pane, Icon, GraphD3 }, 94 | props: ['activeTabId', 'lastSave'], 95 | emits: ['note-open'], 96 | data() { 97 | return { 98 | graphData: null, 99 | initialTransform: null, 100 | query: '', 101 | showSettings: false, 102 | display: { 103 | showTitles: { value: true, title: 'show titles' }, 104 | scaleTitles: { value: true, title: 'auto-scale titles' }, 105 | dynamicNodeRadius: { value: false, title: 'size nodes per links' }, 106 | emphasizeActive: { value: true, title: 'emphasize active note' }, 107 | }, 108 | forces: { 109 | chargeStrength: { value: -30, min: -100, max: 0, step: 1, title: 'repel force' }, 110 | collideRadius: { value: 1, min: 1, max: 50, step: 1, title: 'collide radius' }, 111 | collideStrength: { value: 0.5, min: 0, max: 1, step: 0.05, title: 'collide strength' }, 112 | }, 113 | } 114 | }, 115 | methods: { 116 | fetchGraph() { 117 | fetch("/api/notes") 118 | .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e))) 119 | .then(response => { 120 | let nodes = []; 121 | let links = []; 122 | const notes = Object.values(response); 123 | notes.forEach(note => { 124 | nodes.push({ id: note.Filename, title: note.Title, isLabel: note.IsLabel }); 125 | if (note.OutgoingLinks) { 126 | note.OutgoingLinks.forEach(link => { 127 | if (link.Title !== '') links.push({ source: note.Filename, target: link.Filename }); 128 | }); 129 | } 130 | }); 131 | this.graphData = { nodes, links }; 132 | }) 133 | .catch(e => { 134 | console.error(e); 135 | }); 136 | }, 137 | }, 138 | computed: { 139 | emphasizeNodeIds() { 140 | if (this.showSettings && this.query) { 141 | const queryWords = this.query.toLowerCase().split(' '); 142 | return this.graphData.nodes.filter(node => queryWords.every(queryWord => node.title.toLowerCase().includes(queryWord))).map(node => node.id); 143 | } 144 | return (this.display.emphasizeActive.value && this.activeTabId) ? [this.activeTabId] : null; 145 | }, 146 | }, 147 | created() { 148 | this.fetchGraph(); 149 | }, 150 | watch: { 151 | 'lastSave': function() { 152 | this.initialTransform = this.$refs.forcegraph.zoomTransform; 153 | this.graphData = null; 154 | this.fetchGraph(); 155 | }, 156 | }, 157 | template: t 158 | } 159 | -------------------------------------------------------------------------------- /web/app/graph-d3.js: -------------------------------------------------------------------------------- 1 | var t = ` 2 |
3 | ` 4 | 5 | export default { 6 | props: [ 7 | 'graphData', 8 | 'emphasizeNodeIds', 9 | 'initialTransform', 10 | 'display', 11 | 'forces', 12 | ], 13 | emits: ['title-click'], 14 | data() { 15 | return { 16 | zoomTransform: null, 17 | } 18 | }, 19 | methods: { 20 | initGraph() { 21 | const vm = this; 22 | vm.$refs.forcegraph.innerHTML = ""; 23 | 24 | const links = this.graphData.links.map(d => Object.create(d)); 25 | const nodes = this.graphData.nodes.map(d => Object.create(d)); 26 | 27 | const svg = d3.select(vm.$refs.forcegraph).append("svg") 28 | .style("height", "inherit") 29 | .style("width", "inherit") 30 | .attr("viewBox", [-140, -180, 320, 360]); 31 | 32 | const simulation = d3.forceSimulation(nodes) 33 | .force("link", d3.forceLink(links).id(d => d.id)) 34 | .force("charge", d3.forceManyBody()) 35 | .force("collide", d3.forceCollide()) 36 | .force("center", d3.forceCenter()) 37 | .force("x", d3.forceX()) 38 | .force("y", d3.forceY()); 39 | 40 | const link = svg.append("g") 41 | .classed("link", true) 42 | .attr("stroke", "currentColor") 43 | .selectAll("line") 44 | .data(links) 45 | .join("line"); 46 | 47 | const node = svg.append("g") 48 | .selectAll("circle") 49 | .data(nodes) 50 | .join("circle") 51 | .attr("r", 2) 52 | .classed("node", true) 53 | .classed("node-label", n => (n.isLabel)) 54 | .call(drag(simulation)); 55 | 56 | const title = svg.append("g") 57 | .selectAll("circle") 58 | .data(nodes) 59 | .enter() 60 | .append("text") 61 | .classed("title", true) 62 | .on("click", function(event, node) { event.stopPropagation(); vm.$emit('title-click', node.id); }) 63 | .text(node => node.title); 64 | 65 | const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) { 66 | svg.selectAll('g').attr('transform', event.transform); 67 | if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(event.transform.k); 68 | vm.zoomTransform = { 69 | k: event.transform.k, 70 | x: event.transform.x, 71 | y: event.transform.y 72 | } 73 | }); 74 | svg.call(zoom); 75 | 76 | if (vm.initialTransform) { 77 | const {k, x, y} = vm.initialTransform; 78 | const transform = d3.zoomIdentity.translate(x, y).scale(k); 79 | svg.selectAll('g').attr('transform', transform); 80 | svg.call(zoom.transform, transform); 81 | if (vm.display.scaleTitles.value) scaleTitlesByZoomLevel(k); 82 | } 83 | 84 | simulation.on("tick", () => { 85 | link 86 | .attr("x1", d => d.source.x) 87 | .attr("y1", d => d.source.y) 88 | .attr("x2", d => d.target.x) 89 | .attr("y2", d => d.target.y); 90 | node 91 | .attr("cx", d => d.x) 92 | .attr("cy", d => d.y); 93 | title 94 | .attr('x', d => d.x + 4).attr('y', d => d.y); 95 | }); 96 | 97 | function drag(simulation) { 98 | function dragstarted(event, d) { 99 | if (!event.active) simulation.alphaTarget(0.3).restart(); 100 | d.fx = d.x; 101 | d.fy = d.y; 102 | } 103 | function dragged(event, d) { 104 | d.fx = event.x; 105 | d.fy = event.y; 106 | } 107 | function dragended(event, d) { 108 | if (!event.active) simulation.alphaTarget(0); 109 | d.fx = null; 110 | d.fy = null; 111 | } 112 | return d3.drag() 113 | .on("start", dragstarted) 114 | .on("drag", dragged) 115 | .on("end", dragended); 116 | } 117 | 118 | function scaleTitlesByZoomLevel(k) { 119 | const titleSize = k > 0.9 ? 5 - k : 0; 120 | svg.selectAll('.title').transition().style("font-size", titleSize + "px"); 121 | } 122 | 123 | vm.$watch('display.scaleTitles.value', function(enabled) { 124 | const k = enabled ? d3.zoomTransform(svg.node()).k : 1; 125 | scaleTitlesByZoomLevel(k); 126 | }); 127 | 128 | vm.$watch('display.showTitles.value', function(enabled) { 129 | svg.selectAll('.title').classed("hidden", !enabled); 130 | }); 131 | 132 | function emphasizeNodes(nodeIds) { 133 | if (nodeIds && nodeIds.length > 0) { 134 | const linkedNodeIds = Array.from(new Set(vm.graphData.links 135 | .filter(l => nodeIds.includes(l.source) || nodeIds.includes(l.target)) 136 | .flatMap(l => [l.source, l.target]))); 137 | 138 | node.attr("fill-opacity", 0.05); 139 | title.attr("fill-opacity", 0.15).attr("font-weight", "normal"); 140 | link.attr("stroke", "currentColor").attr("stroke-opacity", 0.15); 141 | 142 | node.filter(n => linkedNodeIds.includes(n.id)).attr("fill-opacity", 0.3); 143 | title.filter(t => linkedNodeIds.includes(t.id)).attr("fill-opacity", 1); 144 | 145 | node.filter(n => nodeIds.includes(n.id)).attr("fill-opacity", 1); 146 | title.filter(t => nodeIds.includes(t.id)).attr("fill-opacity", 1).attr("font-weight", "bold"); 147 | link.filter(l => nodeIds.includes(l.source.id) || nodeIds.includes(l.target.id)).attr("stroke-opacity", 1); 148 | 149 | } else { 150 | node.attr("fill-opacity", 1) 151 | title.attr("fill-opacity", 1).attr("font-weight", "normal"); 152 | link.attr("stroke", "currentColor").attr("stroke-opacity", 1); 153 | } 154 | } 155 | 156 | vm.$watch('emphasizeNodeIds', nodeIds => emphasizeNodes(nodeIds)); 157 | if (this.emphasizeNodeIds?.length > 0) emphasizeNodes(this.emphasizeNodeIds); 158 | 159 | vm.$watch('display.dynamicNodeRadius.value', function(enabled) { 160 | function getLinkCount(nodeId) { 161 | return vm.graphData.links.reduce((count, link) => (link.source === nodeId || link.target === nodeId) ? count + 1 : count, 0); 162 | } 163 | 164 | if (enabled) { 165 | const totalNodes = vm.graphData.nodes.length; 166 | const linkCounts = vm.graphData.nodes.map(n => getLinkCount(n.id)); 167 | const maxLinks = Math.max(...linkCounts); 168 | 169 | const minBaseRadius = 1; 170 | const maxBaseRadius = 5; 171 | const baseRadius = Math.max(minBaseRadius, Math.min(maxBaseRadius, 5 - totalNodes / 5)); 172 | 173 | const minRadiusIncrement = 0.1; 174 | const maxRadiusIncrement = 0.5; 175 | const radiusIncrement = Math.max(minRadiusIncrement, Math.min(maxRadiusIncrement, 5 / maxLinks)); 176 | 177 | node.attr("r", n => baseRadius + (getLinkCount(n.id) * radiusIncrement)); 178 | } else { 179 | node.attr("r", 2); 180 | } 181 | }); 182 | 183 | vm.$watch('forces.chargeStrength.value', function(value) { 184 | simulation.force("charge", d3.forceManyBody().strength(value)); 185 | simulation.alpha(1).restart(); 186 | }); 187 | 188 | vm.$watch('forces.collideRadius.value', function(value) { 189 | simulation.force("collide").radius(value); 190 | simulation.alpha(1).restart(); 191 | }); 192 | 193 | vm.$watch('forces.collideStrength.value', function(value) { 194 | simulation.force("collide").strength(value); 195 | simulation.alpha(1).restart(); 196 | }); 197 | 198 | }, 199 | }, 200 | mounted() { 201 | this.initGraph(); 202 | }, 203 | template: t 204 | } 205 | -------------------------------------------------------------------------------- /web/app/cm-table.js: -------------------------------------------------------------------------------- 1 | function isTableRow(cm, lineNum) { 2 | return cm.getLine(lineNum).trim().startsWith('|'); 3 | } 4 | 5 | function findTableBoundaries(cm, lineNum) { 6 | let startLine = lineNum, endLine = lineNum; 7 | while (startLine > 0 && isTableRow(cm, startLine - 1)) startLine--; 8 | while (endLine < cm.lineCount() - 1 && isTableRow(cm, endLine + 1)) endLine++; 9 | return { startLine, endLine }; 10 | } 11 | 12 | function getColumnAlignments(cm, lineNum) { 13 | return cm.getLine(lineNum).split('|').slice(1, -1).map(col => { 14 | const trimmedCol = col.trim(); 15 | if (trimmedCol.startsWith(':') && trimmedCol.endsWith(':')) return 'center' 16 | if (trimmedCol.endsWith(':')) return 'right'; 17 | return 'left'; 18 | }); 19 | } 20 | 21 | function getColumnPositions(cm, lineNum) { 22 | const lineText = cm.getLine(lineNum); 23 | let positions = []; 24 | let pos = lineText.indexOf('|'); 25 | while (pos !== -1) { positions.push(pos); pos = lineText.indexOf('|', pos + 1); } 26 | return positions; 27 | } 28 | 29 | function getColumnMaxLengths(cm, startLine, endLine, conceal) { 30 | let maxLengths = []; 31 | for (let lineNum = startLine; lineNum <= endLine; lineNum++) { 32 | if (lineNum == startLine + 1) continue; 33 | const columns = cm.getLine(lineNum).trim().split('|').map(col => col.trim()); 34 | columns.slice(1, -1).forEach((col, index) => { 35 | const colLength = conceal ? getConcealLength(col) : col.length; 36 | if (!maxLengths[index] || colLength > maxLengths[index]) { 37 | maxLengths[index] = colLength; 38 | } 39 | }); 40 | } 41 | return maxLengths; 42 | } 43 | 44 | function formatRowSep(cm, lineNum, colMaxLengths, colAlignments) { 45 | const line = cm.getLine(lineNum); 46 | let columns = line.split('|'); 47 | columns = columns.map((col, index) => { 48 | if (index === 0 || index === columns.length - 1) return col; 49 | switch (colAlignments[index - 1]) { 50 | case 'center': return ` :${"-".repeat(colMaxLengths[index - 1] - 2)}: `; 51 | case 'right': return ` ${"-".repeat(colMaxLengths[index - 1] - 1)}: `; 52 | default: return ` ${"-".repeat(colMaxLengths[index - 1])} `; 53 | } 54 | }); 55 | cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length}); 56 | } 57 | 58 | function formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal) { 59 | const line = cm.getLine(lineNum); 60 | let columns = line.split('|'); 61 | columns = columns.map((col, index) => { 62 | if (index === 0 || index === columns.length - 1) return col; 63 | const colTrimmed = col.trim(); 64 | const colLength = conceal ? getConcealLength(colTrimmed) : colTrimmed.length; 65 | const paddingLength = Math.max(0, colMaxLengths[index - 1] - colLength); 66 | const halfPadding = Math.floor(paddingLength / 2); 67 | switch (colAlignments[index - 1]) { 68 | case 'center': return ` ${' '.repeat(halfPadding)}${colTrimmed}${' '.repeat(paddingLength - halfPadding)} `; 69 | case 'right': return ` ${' '.repeat(paddingLength)}${colTrimmed} `; 70 | default: return ` ${colTrimmed}${' '.repeat(paddingLength)} `; 71 | } 72 | }); 73 | cm.replaceRange(columns.join('|'), {line: lineNum, ch: 0}, {line: lineNum, ch: line.length}); 74 | } 75 | 76 | function formatRows(cm, conceal) { 77 | const cursorPos = cm.getCursor(); 78 | if (!isTableRow(cm, cursorPos.line)) return; 79 | 80 | const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line); 81 | const colAlignments = getColumnAlignments(cm, startLine + 1); 82 | const colMaxLengths = getColumnMaxLengths(cm, startLine, endLine, conceal); 83 | 84 | for (let lineNum = startLine; lineNum <= endLine; lineNum++) { 85 | if (lineNum == startLine + 1) { 86 | formatRowSep(cm, lineNum, colMaxLengths, colAlignments); 87 | } else { 88 | formatRow(cm, lineNum, colMaxLengths, colAlignments, conceal); 89 | } 90 | } 91 | } 92 | 93 | function addOrUpdateRowSep(cm) { 94 | const cursorPos = cm.getCursor(); 95 | const { startLine, endLine } = findTableBoundaries(cm, cursorPos.line); 96 | if (startLine !== cursorPos.line) return; 97 | 98 | const columnsCount = getColumnPositions(cm, startLine).length; 99 | if (startLine == endLine) { 100 | cm.replaceRange("\n" + `${"|".repeat(columnsCount)}`, {line: startLine, ch: cm.getLine(startLine).length}); 101 | return; 102 | } 103 | 104 | const sepLineNum = startLine + 1; 105 | const sepLineText = cm.getLine(sepLineNum); 106 | const columnsCountSep = getColumnPositions(cm, sepLineNum).length; 107 | if (columnsCount > columnsCountSep) { 108 | cm.replaceRange(`${"|".repeat(columnsCount - columnsCountSep)}`, {line: sepLineNum, ch: sepLineText.length}); 109 | } else { 110 | const newSepLineText = sepLineText.split('|').slice(0, columnsCount).join('|') + '|'; 111 | cm.replaceRange(newSepLineText, {line: sepLineNum, ch: 0}, {line: sepLineNum, ch: sepLineText.length}); 112 | } 113 | } 114 | 115 | export function isCursorInTable(cm) { 116 | const cursorPos = cm.getCursor(); 117 | return isTableRow(cm, cursorPos.line); 118 | } 119 | 120 | export function formatTableAndAdvance(cm, conceal) { 121 | const cursorPos = cm.getCursor(); 122 | if (!isTableRow(cm, cursorPos.line)) return; 123 | 124 | const currentPositions = getColumnPositions(cm, cursorPos.line); 125 | const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length; 126 | 127 | if (currentColumn == currentPositions.length) { 128 | cm.replaceRange('|', {line: cursorPos.line, ch: cm.getLine(cursorPos.line).length}); 129 | addOrUpdateRowSep(cm); 130 | formatRows(cm, conceal); 131 | cm.setCursor(cursorPos.line, cm.getLine(cursorPos.line).length); 132 | } else { 133 | formatRows(cm, conceal); 134 | const newPositions = getColumnPositions(cm, cursorPos.line); 135 | cm.setCursor(cursorPos.line, newPositions[currentColumn] + 2); 136 | } 137 | } 138 | 139 | export function navigateTable(cm, direction) { 140 | const cursorPos = cm.getCursor(); 141 | if (!isTableRow(cm, cursorPos.line)) return CodeMirror.Pass; 142 | 143 | const currentPositions = getColumnPositions(cm, cursorPos.line); 144 | const currentColumn = currentPositions.filter(pos => pos < cursorPos.ch).length; 145 | 146 | const moveCursorVertically = (targetLine) => { 147 | const targetPositions = getColumnPositions(cm, targetLine); 148 | const targetCh = currentColumn <= targetPositions.length 149 | ? targetPositions[currentColumn - 1] + 2 150 | : cm.getLine(targetLine).length; 151 | cm.setCursor(targetLine, targetCh); 152 | }; 153 | 154 | switch (direction) { 155 | case 'left': 156 | if (currentColumn > 1) { 157 | cm.setCursor(cursorPos.line, currentPositions[currentColumn - 2] + 2); 158 | } 159 | break; 160 | case 'right': 161 | if (currentColumn < currentPositions.length) { 162 | cm.setCursor(cursorPos.line, currentPositions[currentColumn] + 2); 163 | } 164 | break; 165 | case 'up': 166 | const {startLine} = findTableBoundaries(cm, cursorPos.line); 167 | if (cursorPos.line > startLine) moveCursorVertically(cursorPos.line - 1); 168 | break; 169 | case 'down': 170 | const {endLine} = findTableBoundaries(cm, cursorPos.line); 171 | if (cursorPos.line < endLine) moveCursorVertically(cursorPos.line + 1); 172 | break; 173 | } 174 | } 175 | 176 | function getConcealLength(s) { 177 | s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links 178 | s = s.replace(/(\*\*\*|___)(.*?)\1/g, '$2'); // Bold + Italic 179 | s = s.replace(/(\*\*|__)(.*?)\1/g, '$2'); // Bold 180 | s = s.replace(/(\*|_)(.*?)\1/g, '$2'); // Italic 181 | s = s.replace(/(~~)(.*?)\1/g, '$2'); // Strikethrough 182 | s = s.replace(/(`)(.*?)\1/g, '$2'); // Inline code 183 | return s.length; 184 | } 185 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/charlievieth/fastwalk v1.0.9 h1:Odb92AfoReO3oFBfDGT5J+nwgzQPF/gWAw6E6/lkor0= 2 | github.com/charlievieth/fastwalk v1.0.9/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= 3 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 4 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 5 | github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= 6 | github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= 7 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 8 | github.com/junegunn/fzf v0.58.0 h1:sT6lO4OTkHpEHpr8E1iZz6bvxZ6URHjTYl8/yhS8s1U= 9 | github.com/junegunn/fzf v0.58.0/go.mod h1:IsDYaa3WFbMYYi8yp92fQFTqN10hs3nH4OMBiz/kJXo= 10 | github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 h1:rqzLixVo1c/GQW6px9j1xQmlvQIn+lf/V6M1UQ7IFzw= 11 | github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= 12 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 13 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 14 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 15 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 16 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 17 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 19 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 20 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 21 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 22 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 23 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 24 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 25 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 26 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 27 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 28 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 29 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 30 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 31 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 32 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 33 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 34 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 35 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 36 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 37 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 38 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 39 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 40 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 41 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 44 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 45 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 46 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 47 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 48 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 49 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 59 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 60 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 62 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 63 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 64 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 65 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 66 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 67 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 68 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 69 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 70 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 71 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 72 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 73 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 74 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 75 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 76 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 77 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 78 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 79 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 80 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 81 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 82 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 83 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 84 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 85 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 86 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 87 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 88 | --------------------------------------------------------------------------------