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