Initial fork of github.com/alonswartz/notesium into librenotes: - Source moved to internal/notesium/ (package notesium) - Thin entry point at cmd/librenotes/main.go - Module renamed to git.librete.ch/public/librenotes - main() exposed as notesium.Run() - LICENSE preserved (MIT), NOTICE added with attribution - Web assets and completion.bash co-located with embedding code to satisfy go:embed path constraints Closes #3, #34, #35. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package notesium
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const usage = `Usage: notesium COMMAND [OPTIONS]
|
|
|
|
Commands:
|
|
home Print path to notes directory
|
|
new Print path for a new note
|
|
--verbose Output key:value pairs of related info
|
|
--ctime= Use specified ctime instead of now (YYYY-MM-DDThh:mm:ss)
|
|
list Print list of notes
|
|
--color Color code prefix using ansi escape sequences
|
|
--labels Limit list to only label notes (ie. one word title)
|
|
--orphans Limit list to notes without outgoing or incoming links
|
|
--sort=WORD Sort list by date or alphabetically (ctime|mtime|alpha)
|
|
--prefix=WORD Prefix title with date or linked label (ctime|mtime|label)
|
|
--date=FORMAT Date format for ctime/mtime prefix (default: 2006-01-02)
|
|
links [filename] Print list of links
|
|
--color Color code using ansi escape sequences
|
|
--outgoing Limit list to outgoing links related to filename
|
|
--incoming Limit list to incoming links related to filename
|
|
--dangling Limit list to broken links
|
|
lines Print all lines of notes (ie. fulltext search)
|
|
--color Color code prefix using ansi escape sequences
|
|
--prefix=title Prefix each line with note title
|
|
--filter=QUERY Filter lines by query: AND (space), OR (|), NOT (!)
|
|
stats Print statistics
|
|
--color Color code using ansi escape sequences
|
|
--table Format as table with whitespace delimited columns
|
|
finder Start finder (interactive filter selection TUI)
|
|
--preview Display note preview (toggle with ctrl-/)
|
|
--prompt=STR Set custom prompt text
|
|
-- CMD [OPTS] Input (default: list --color --prefix=label --sort=alpha)
|
|
web Start web server
|
|
--webroot=PATH Path to web root to serve (default: embedded webroot)
|
|
--mount=DIR:URI Additional directory to serve under webroot (experimental)
|
|
--open-browser Launch default web browser with web server URL
|
|
--stop-on-idle Automatically stop when no activity is detected
|
|
--port=INT Port for web server to listen on (default: random)
|
|
--no-check Disable daily new version checks
|
|
--writable Allow writing of notes in NOTESIUM_DIR via API
|
|
extract [path] Print list of embedded files or contents of file path
|
|
version Print version
|
|
--verbose Output key:value pairs of related info
|
|
--check Check if a newer version is available
|
|
|
|
Environment:
|
|
NOTESIUM_DIR Path to notes directory (default: $HOME/notes)
|
|
`
|
|
|
|
type Command struct {
|
|
Name string
|
|
Options any
|
|
}
|
|
|
|
type newOptions struct {
|
|
ctime string
|
|
verbose bool
|
|
}
|
|
|
|
type listOptions struct {
|
|
color Color
|
|
limit string
|
|
prefix string
|
|
sortBy string
|
|
dateFormat string
|
|
}
|
|
|
|
type linksOptions struct {
|
|
color Color
|
|
limit string
|
|
filename string
|
|
}
|
|
|
|
type linesOptions struct {
|
|
color Color
|
|
prefix string
|
|
filter string
|
|
}
|
|
|
|
type finderOptions struct {
|
|
input []string
|
|
prompt string
|
|
preview bool
|
|
}
|
|
|
|
type catOptions struct {
|
|
filename string
|
|
lineNumber int
|
|
}
|
|
|
|
type statsOptions struct {
|
|
color Color
|
|
table bool
|
|
}
|
|
|
|
type webOptions struct {
|
|
host string
|
|
port int
|
|
webroot string
|
|
heartbeat bool
|
|
launchBrowser bool
|
|
readOnly bool
|
|
check bool
|
|
mounts map[string]string
|
|
}
|
|
|
|
type extractOptions struct {
|
|
path string
|
|
}
|
|
|
|
type versionOptions struct {
|
|
verbose bool
|
|
check bool
|
|
}
|
|
|
|
type Color struct {
|
|
Code string
|
|
Reset string
|
|
}
|
|
|
|
func parseOptions(args []string) (Command, error) {
|
|
if len(args) < 1 {
|
|
return Command{Name: "help"}, nil
|
|
}
|
|
|
|
cmd := Command{Name: args[0]}
|
|
switch cmd.Name {
|
|
case "-h", "--help", "help":
|
|
cmd.Name = "help"
|
|
return cmd, nil
|
|
|
|
// backwards compat.
|
|
case "-v", "--version":
|
|
cmd.Name = "version"
|
|
cmd.Options = versionOptions{}
|
|
return cmd, nil
|
|
|
|
case "home":
|
|
if len(args) > 1 {
|
|
return cmd, fmt.Errorf("unrecognized option: %s", args[1])
|
|
}
|
|
return cmd, nil
|
|
|
|
case "new":
|
|
opts := newOptions{}
|
|
for _, opt := range args[1:] {
|
|
switch {
|
|
case opt == "--verbose":
|
|
opts.verbose = true
|
|
case strings.HasPrefix(opt, "--ctime="):
|
|
opts.ctime = strings.TrimPrefix(opt, "--ctime=")
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "list":
|
|
opts := listOptions{}
|
|
opts.dateFormat = "2006-01-02"
|
|
for _, opt := range args[1:] {
|
|
switch {
|
|
case opt == "--color":
|
|
opts.color = defaultColor()
|
|
case opt == "--labels":
|
|
opts.limit = "labels"
|
|
case opt == "--orphans":
|
|
opts.limit = "orphans"
|
|
case opt == "--prefix=ctime":
|
|
opts.prefix = "ctime"
|
|
case opt == "--prefix=mtime":
|
|
opts.prefix = "mtime"
|
|
case opt == "--prefix=label":
|
|
opts.prefix = "label"
|
|
case opt == "--sort=ctime":
|
|
opts.sortBy = "ctime"
|
|
case opt == "--sort=mtime":
|
|
opts.sortBy = "mtime"
|
|
case opt == "--sort=alpha":
|
|
opts.sortBy = "alpha"
|
|
case strings.HasPrefix(opt, "--date="):
|
|
opts.dateFormat = strings.TrimPrefix(opt, "--date=")
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "links":
|
|
opts := linksOptions{}
|
|
filenameRequired := false
|
|
for _, opt := range args[1:] {
|
|
switch {
|
|
case opt == "--color":
|
|
opts.color = defaultColor()
|
|
case opt == "--dangling":
|
|
opts.limit = "dangling"
|
|
case opt == "--outgoing":
|
|
opts.limit = map[bool]string{true: "", false: "outgoing"}[opts.limit == "incoming"]
|
|
filenameRequired = true
|
|
case opt == "--incoming":
|
|
opts.limit = map[bool]string{true: "", false: "incoming"}[opts.limit == "outgoing"]
|
|
filenameRequired = true
|
|
case strings.HasPrefix(opt, "--filename=") && strings.HasSuffix(opt, ".md"):
|
|
opts.filename = strings.TrimPrefix(opt, "--filename=")
|
|
case strings.HasSuffix(opt, ".md"):
|
|
opts.filename = opt
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
if opts.filename != "" && opts.limit == "dangling" {
|
|
return Command{}, fmt.Errorf("filename not supported")
|
|
}
|
|
if opts.filename == "" && filenameRequired {
|
|
return Command{}, fmt.Errorf("filename is required")
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "lines":
|
|
opts := linesOptions{}
|
|
for _, opt := range args[1:] {
|
|
switch {
|
|
case opt == "--color":
|
|
opts.color = defaultColor()
|
|
case opt == "--prefix=title":
|
|
opts.prefix = "title"
|
|
case strings.HasPrefix(opt, "--filter="):
|
|
opts.filter = strings.TrimPrefix(opt, "--filter=")
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "finder":
|
|
opts := finderOptions{}
|
|
opts.input = []string{"list", "--color", "--prefix=label", "--sort=alpha"}
|
|
for i, opt := range args[1:] {
|
|
if opt == "--" {
|
|
opts.input = args[i+2:]
|
|
if len(opts.input) == 0 {
|
|
return Command{}, fmt.Errorf("input command not specified")
|
|
}
|
|
break
|
|
}
|
|
switch {
|
|
case opt == "--preview":
|
|
opts.preview = true
|
|
case strings.HasPrefix(opt, "--prompt="):
|
|
opts.prompt = strings.TrimPrefix(opt, "--prompt=")
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "stats":
|
|
opts := statsOptions{}
|
|
for _, opt := range args[1:] {
|
|
switch opt {
|
|
case "--color":
|
|
opts.color = defaultColor()
|
|
case "--table":
|
|
opts.table = true
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "web":
|
|
opts := webOptions{}
|
|
opts.host = "127.0.0.1"
|
|
opts.port = 0
|
|
opts.readOnly = true
|
|
opts.webroot = "embedded"
|
|
opts.check = true
|
|
opts.mounts = make(map[string]string)
|
|
for _, opt := range args[1:] {
|
|
switch {
|
|
case strings.HasPrefix(opt, "--webroot="):
|
|
webrootStr := strings.TrimPrefix(opt, "--webroot=")
|
|
webrootAbs, err := getAbsDir(webrootStr)
|
|
if err != nil {
|
|
return Command{}, fmt.Errorf("webroot %v: %s", err, webrootAbs)
|
|
}
|
|
opts.webroot = webrootAbs
|
|
case opt == "--open-browser":
|
|
opts.launchBrowser = true
|
|
case opt == "--stop-on-idle":
|
|
opts.heartbeat = true
|
|
case strings.HasPrefix(opt, "--port="):
|
|
portStr := strings.TrimPrefix(opt, "--port=")
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil || port < 1024 || port > 65535 {
|
|
return Command{}, fmt.Errorf("invalid or out of range port number: %s", portStr)
|
|
}
|
|
opts.port = port
|
|
case opt == "--no-check":
|
|
opts.check = false
|
|
case opt == "--writable":
|
|
opts.readOnly = false
|
|
case strings.HasPrefix(opt, "--mount="):
|
|
mountStr := strings.TrimPrefix(opt, "--mount=")
|
|
mountPattern := regexp.MustCompile(`^(.+?):(/[a-zA-Z0-9-_]+/)$`)
|
|
matches := mountPattern.FindStringSubmatch(mountStr)
|
|
if matches == nil {
|
|
return Command{}, fmt.Errorf("mount format mismatch: expected '%s'", mountPattern.String())
|
|
}
|
|
srcAbs, err := getAbsDir(matches[1])
|
|
if err != nil {
|
|
return Command{}, fmt.Errorf("mount source %v: %s", err, srcAbs)
|
|
}
|
|
opts.mounts[matches[2]] = srcAbs
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "cat":
|
|
opts := catOptions{}
|
|
opts.lineNumber = 0
|
|
if len(args) != 2 {
|
|
return Command{}, fmt.Errorf("filename not specified or too many arguments")
|
|
}
|
|
|
|
parts := strings.Split(args[1], ":")
|
|
opts.filename = parts[0]
|
|
if len(parts) > 1 {
|
|
if num, err := strconv.Atoi(parts[1]); err == nil && num > 0 {
|
|
opts.lineNumber = num
|
|
}
|
|
}
|
|
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "version":
|
|
opts := versionOptions{}
|
|
for _, opt := range args[1:] {
|
|
switch opt {
|
|
case "--verbose":
|
|
opts.verbose = true
|
|
case "--check":
|
|
opts.check = true
|
|
default:
|
|
return Command{}, fmt.Errorf("unrecognized option: %s", opt)
|
|
}
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
case "extract":
|
|
opts := extractOptions{}
|
|
for _, opt := range args[1:] {
|
|
opts.path = opt
|
|
}
|
|
cmd.Options = opts
|
|
return cmd, nil
|
|
|
|
default:
|
|
if strings.HasPrefix(cmd.Name, "-") {
|
|
return cmd, fmt.Errorf("unrecognized option: %s", cmd.Name)
|
|
}
|
|
return cmd, fmt.Errorf("unrecognized command: %s", cmd.Name)
|
|
}
|
|
}
|
|
|
|
func getNotesiumDir() (string, error) {
|
|
dir, exists := os.LookupEnv("NOTESIUM_DIR")
|
|
if !exists {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir = filepath.Join(home, "notes")
|
|
}
|
|
absDir, err := getAbsDir(dir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("NOTESIUM_DIR %v: %s", err, absDir)
|
|
}
|
|
return absDir, nil
|
|
}
|
|
|
|
func getAbsDir(dir string) (string, error) {
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return dir, fmt.Errorf("failed to resolve absolute path: %v", err)
|
|
}
|
|
realDir, err := filepath.EvalSymlinks(absDir)
|
|
if err != nil {
|
|
return absDir, fmt.Errorf("does not exist")
|
|
}
|
|
info, err := os.Stat(realDir)
|
|
if err != nil {
|
|
return realDir, fmt.Errorf("does not exist")
|
|
}
|
|
if !info.IsDir() {
|
|
return realDir, fmt.Errorf("is not a directory")
|
|
}
|
|
return realDir, nil
|
|
}
|
|
|
|
func defaultColor() Color {
|
|
return Color{
|
|
Code: "\033[0;36m",
|
|
Reset: "\033[0m",
|
|
}
|
|
}
|