diff --git a/go.mod b/go.mod index ca5e00a..a69e895 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module git.javil.eu/jacob1123/docspell-cli go 1.23.4 -require bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 +require ( + bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 + github.com/integrii/flaggy v1.5.2 +) require golang.org/x/sys v0.4.0 // indirect diff --git a/go.sum b/go.sum index 8bbf2cc..8acd6b7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,11 @@ bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ= bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/integrii/flaggy v1.5.2 h1:bWV20MQEngo4hWhno3i5Z9ISPxLPKj9NOGNwTWb/8IQ= +github.com/integrii/flaggy v1.5.2/go.mod h1:dO13u7SYuhk910nayCJ+s1DeAAGC1THCMj1uSFmwtQ8= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 243e2c3..94915eb 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,14 @@ package main import ( "encoding/json" - "flag" "fmt" "os" "os/exec" "path/filepath" "strings" "time" + + "github.com/integrii/flaggy" ) type config struct { @@ -38,80 +39,84 @@ type DocspellItem struct { } func main() { + parser, commands := newCLIParser() if len(os.Args) < 2 { - printUsage(os.Stderr) + parser.ShowHelp() return } + if err := parser.ParseArgs(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } - switch os.Args[1] { - case "import": - if isHelpRequest(os.Args[2:]) { - runImportCommand(config{}, "", os.Args[2:]) - return - } + switch { + case commands.importCommand.Used: cfg, password := loadConfigAndPassword() runImportCommand(cfg, password, os.Args[2:]) - case "mount": - if isHelpRequest(os.Args[2:]) { - _ = runMountCommand(os.Args[2:], config{}, "") - return - } + case commands.mountCommand.Used: cfg, password := loadConfigAndPassword() if err := runMountCommand(os.Args[2:], cfg, password); err != nil { fmt.Fprintln(os.Stderr, "mount failed:", err) os.Exit(1) } - case "get-path": - if isHelpRequest(os.Args[2:]) { - _ = runGetPathCommand(os.Args[2:], config{}, "") - return - } + case commands.getPathCommand.Used: cfg, password := loadConfigAndPassword() if err := runGetPathCommand(os.Args[2:], cfg, password); err != nil { fmt.Fprintln(os.Stderr, "get-path failed:", err) os.Exit(1) } - case "get-url": - if isHelpRequest(os.Args[2:]) { - _ = runGetURLCommand(os.Args[2:], config{}, "") - return - } + case commands.getURLCommand.Used: cfg, password := loadConfigAndPassword() if err := runGetURLCommand(os.Args[2:], cfg, password); err != nil { fmt.Fprintln(os.Stderr, "get-url failed:", err) os.Exit(1) } - case "-h", "--help", "help": - printUsage(os.Stdout) default: - fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1]) - printUsage(os.Stderr) - os.Exit(2) + parser.ShowHelp() } } -func isHelpRequest(args []string) bool { - for _, arg := range args { - if arg == "-h" || arg == "--help" { - return true - } - } - return false +type cliCommands struct { + importCommand *flaggy.Subcommand + mountCommand *flaggy.Subcommand + getPathCommand *flaggy.Subcommand + getURLCommand *flaggy.Subcommand } -func printUsage(out *os.File) { - name := filepath.Base(os.Args[0]) - fmt.Fprintf(out, `Usage: - %[1]s [flags] +func newCLIParser() (*flaggy.Parser, cliCommands) { + parser := flaggy.NewParser(filepath.Base(os.Args[0])) + parser.Description = "Docspell import, archive, mount, and helper CLI" + parser.ShowHelpOnUnexpected = true + parser.DisableShowVersionWithVersion() -Commands: - import Scan configured import folders, archive confirmed files, and optionally upload missing files - mount Expose Docspell search results as a read-only FUSE filesystem - get-path Resolve a Docspell item ID or URL to the local mounted path - get-url Resolve a local mounted path, item ID, or item URL to a Docspell web URL + commands := cliCommands{ + importCommand: flaggy.NewSubcommand("import"), + mountCommand: flaggy.NewSubcommand("mount"), + getPathCommand: flaggy.NewSubcommand("get-path"), + getURLCommand: flaggy.NewSubcommand("get-url"), + } + commands.importCommand.Description = "Scan configured import folders, archive confirmed files, and optionally upload missing files" + commands.mountCommand.Description = "Expose Docspell search results as a read-only FUSE filesystem" + commands.getPathCommand.Description = "Resolve a Docspell item ID or URL to the local mounted path" + commands.getURLCommand.Description = "Resolve a local mounted path, item ID, or item URL to a Docspell web URL" -Run "%[1]s -h" for command-specific help. -`, name) + var uploadMissing bool + commands.importCommand.Bool(&uploadMissing, "", "upload-missing", "true, to upload files to docspell that do not yet exist there") + var query, mountPoint, itemIDOrURL, pathOrID string + authHeader := defaultDocspellAuthHeader + limit := 1000 + commands.mountCommand.String(&query, "", "query", "Docspell item query to expose") + commands.mountCommand.Int(&limit, "", "limit", "number of items to fetch per API request") + commands.mountCommand.String(&authHeader, "", "auth-header", "HTTP header used for the Docspell auth token") + commands.mountCommand.AddPositionalValue(&mountPoint, "mountpoint", 1, false, "mount point, overriding mountPath from config") + commands.getPathCommand.AddPositionalValue(&itemIDOrURL, "item-id-or-url", 1, true, "Docspell item ID or web URL") + commands.getURLCommand.AddPositionalValue(&pathOrID, "local-path-or-item-id", 1, true, "local mounted path, item ID, or item URL") + + parser.AttachSubcommand(commands.importCommand, 1) + parser.AttachSubcommand(commands.mountCommand, 1) + parser.AttachSubcommand(commands.getPathCommand, 1) + parser.AttachSubcommand(commands.getURLCommand, 1) + return parser, commands } func loadConfigAndPassword() (config, string) { @@ -145,9 +150,8 @@ func loadConfigAndPassword() (config, string) { } func runImportCommand(cfg config, password string, args []string) { - importFlags := flag.NewFlagSet("import", flag.ExitOnError) - uploadMissing := importFlags.Bool("upload-missing", os.Getenv("DS_CC_UPLOAD_MISSING") == "true", "true, to upload files to docspell that do not yet exist there") - if err := importFlags.Parse(args); err != nil { + options, err := parseImportOptions(args) + if err != nil { fmt.Println("Error parsing flags:", err) os.Exit(1) } @@ -161,7 +165,7 @@ func runImportCommand(cfg config, password string, args []string) { } fmt.Println("Settings:") - if *uploadMissing { + if options.uploadMissing { fmt.Println(" - UPLOAD files? YES") fmt.Println(" files not existing in Docspell will be uploaded and will be re-checked in the next run.") } else { @@ -211,7 +215,7 @@ func runImportCommand(cfg config, password string, args []string) { } } else { fmt.Println(" Files does not exist, yet") - if *uploadMissing { + if options.uploadMissing { fmt.Print(" ...uploading file..") cmd = exec.Command("dsc", "-f", "json", "upload", path) output, err := cmd.Output() @@ -244,6 +248,20 @@ func runImportCommand(cfg config, password string, args []string) { } } +type importOptions struct { + uploadMissing bool +} + +func parseImportOptions(args []string) (importOptions, error) { + options := importOptions{uploadMissing: os.Getenv("DS_CC_UPLOAD_MISSING") == "true"} + parser := flaggy.NewParser("import") + parser.Description = "Scan configured import folders, archive confirmed files, and optionally upload missing files" + parser.DisableShowVersionWithVersion() + parser.ShowHelpOnUnexpected = true + parser.Bool(&options.uploadMissing, "", "upload-missing", "true, to upload files to docspell that do not yet exist there") + return options, parser.ParseArgs(args) +} + func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error { // File exists in Docspell items := fileExistsResponse.Items diff --git a/mount.go b/mount.go index caad76c..3b4839f 100644 --- a/mount.go +++ b/mount.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "flag" "fmt" "os" "os/exec" @@ -15,6 +14,7 @@ import ( "bazil.org/fuse" "bazil.org/fuse/fs" "bazil.org/fuse/fuseutil" + "github.com/integrii/flaggy" ) type docspellFS struct { @@ -54,42 +54,32 @@ type mountedItemMetadata struct { const mountMetadataFileName = ".docspell-cli-metadata.json" func runMountCommand(args []string, cfg config, password string) error { - fsFlags := flag.NewFlagSet("mount", flag.ExitOnError) - query := fsFlags.String("query", cfg.MountQuery, "Docspell item query to expose") - limit := fsFlags.Int("limit", 1000, "number of items to fetch per API request") - authHeader := fsFlags.String("auth-header", defaultDocspellAuthHeader, "HTTP header used for the Docspell auth token") - if err := fsFlags.Parse(args); err != nil { + options, err := parseMountOptions(args, cfg) + if err != nil { return err } - mountPoint := cfg.MountPath - if fsFlags.NArg() > 1 { - return fmt.Errorf("usage: %s mount [flags] [mountpoint]", filepath.Base(os.Args[0])) - } - if fsFlags.NArg() == 1 { - mountPoint = fsFlags.Arg(0) - } - if mountPoint == "" { + if options.mountPoint == "" { return fmt.Errorf("mount point missing: pass one or set MountPath in config") } if os.Getenv("DOCSPELL_CLI_MOUNT_FOREGROUND") != "1" { - return startMountInBackground(args, mountPoint, false) + return startMountInBackground(args, options.mountPoint, false) } - if err := os.MkdirAll(mountPoint, 0755); err != nil { + if err := os.MkdirAll(options.mountPoint, 0755); err != nil { return err } - client := NewDocspellClient(cfg.DocspellURL, *authHeader) + client := NewDocspellClient(cfg.DocspellURL, options.authHeader) if err := client.Login(context.Background(), cfg.User, password); err != nil { return err } - items, err := client.SearchItems(context.Background(), *query, *limit) + items, err := client.SearchItems(context.Background(), options.query, options.limit) if err != nil { return fmt.Errorf("search items: %w", err) } root := buildMountTree(context.Background(), client, items) conn, err := fuse.Mount( - mountPoint, + options.mountPoint, fuse.FSName("docspell"), fuse.Subtype("docspell-cli"), fuse.ReadOnly(), @@ -99,10 +89,35 @@ func runMountCommand(args []string, cfg config, password string) error { } defer conn.Close() - fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), mountPoint, mountPoint) + fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), options.mountPoint, options.mountPoint) return fs.Serve(conn, &docspellFS{root: root}) } +type mountOptions struct { + query string + limit int + authHeader string + mountPoint string +} + +func parseMountOptions(args []string, cfg config) (mountOptions, error) { + options := mountOptions{ + query: cfg.MountQuery, + limit: 1000, + authHeader: defaultDocspellAuthHeader, + mountPoint: cfg.MountPath, + } + parser := flaggy.NewParser("mount") + parser.Description = "Expose Docspell search results as a read-only FUSE filesystem" + parser.DisableShowVersionWithVersion() + parser.ShowHelpOnUnexpected = true + parser.String(&options.query, "", "query", "Docspell item query to expose") + parser.Int(&options.limit, "", "limit", "number of items to fetch per API request") + parser.String(&options.authHeader, "", "auth-header", "HTTP header used for the Docspell auth token") + parser.AddPositionalValue(&options.mountPoint, "mountpoint", 1, false, "mount point, overriding mountPath from config") + return options, parser.ParseArgs(args) +} + func startMountInBackground(args []string, mountPoint string, quiet bool) error { mountPoint = cleanMountPath(mountPoint) if isMounted(mountPoint) { @@ -133,18 +148,15 @@ func startMountInBackground(args []string, mountPoint string, quiet bool) error } func runGetPathCommand(args []string, cfg config, password string) error { - flags := flag.NewFlagSet("get-path", flag.ExitOnError) - if err := flags.Parse(args); err != nil { + itemIDOrURL, err := parseSingleArgumentCommand("get-path", "item-id-or-url", "Docspell item ID or web URL", args) + if err != nil { return err } - if flags.NArg() != 1 { - return fmt.Errorf("usage: %s get-path ", filepath.Base(os.Args[0])) - } mountPoint, err := ensureMounted(cfg, password) if err != nil { return err } - id := itemIDFromURLOrID(flags.Arg(0)) + id := itemIDFromURLOrID(itemIDOrURL) if path, err := friendlyLocalPathForItemID(mountPoint, id); err == nil && path != "" { fmt.Println(path) return nil @@ -183,16 +195,11 @@ func readMountMetadata(mountPoint string) (mountMetadata, error) { } func runGetURLCommand(args []string, cfg config, password string) error { - flags := flag.NewFlagSet("get-url", flag.ExitOnError) - if err := flags.Parse(args); err != nil { + id, err := parseSingleArgumentCommand("get-url", "local-path-or-item-id", "local mounted path, item ID, or item URL", args) + if err != nil { return err } - if flags.NArg() != 1 { - return fmt.Errorf("usage: %s get-url ", filepath.Base(os.Args[0])) - } - id := flags.Arg(0) if strings.Contains(id, string(os.PathSeparator)) || strings.HasPrefix(id, ".") { - var err error id, err = itemIDFromLocalPath(id, cfg) if err != nil { return err @@ -202,6 +209,15 @@ func runGetURLCommand(args []string, cfg config, password string) error { return nil } +func parseSingleArgumentCommand(commandName, argName, argDescription string, args []string) (string, error) { + var value string + parser := flaggy.NewParser(commandName) + parser.DisableShowVersionWithVersion() + parser.ShowHelpOnUnexpected = true + parser.AddPositionalValue(&value, argName, 1, true, argDescription) + return value, parser.ParseArgs(args) +} + func ensureMounted(cfg config, password string) (string, error) { mountPoint := cleanMountPath(cfg.MountPath) if mountPoint == "" {