package main import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/integrii/flaggy" ) type config struct { PasswordCommand string PasswordCommandArgs []string User string DocspellURL string MountPath string MountQuery string ArchiveDirectory string ImportDirectories []string } type FileExistsResult struct { Exists bool `json:"exists"` Items []DocspellItem `json:"items"` File string `json:"file"` } type DocspellItem struct { ID string `json:"id"` Name string `json:"name"` Direction string `json:"direction"` State string `json:"state"` Created int64 `json:"created"` ItemDate int64 `json:"itemDate"` } func main() { parser, commands := newCLIParser() if len(os.Args) < 2 { parser.ShowHelp() return } if err := parser.ParseArgs(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(2) } switch { case commands.importCommand.Used: cfg, password := loadConfigAndPassword() runImportCommand(cfg, password, os.Args[2:]) 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 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 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) } default: parser.ShowHelp() } } type cliCommands struct { importCommand *flaggy.Subcommand mountCommand *flaggy.Subcommand getPathCommand *flaggy.Subcommand getURLCommand *flaggy.Subcommand } 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 := 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" 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) { configFile, err := configFilePath() if err != nil { fmt.Println("Error finding config directory:", err) os.Exit(1) } configData, err := os.ReadFile(configFile) if err != nil { fmt.Printf("Error reading config file %s: %v\n", configFile, err) os.Exit(1) } var cfg config if err := json.Unmarshal(configData, &cfg); err != nil { fmt.Println("Error parsing config file:", err) os.Exit(1) } cmd := exec.Command(cfg.PasswordCommand, cfg.PasswordCommandArgs...) password, err := cmd.Output() if err != nil { fmt.Println("Error getting password:", err) os.Exit(1) } return cfg, strings.TrimSpace(string(password)) } func configFilePath() (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", err } return filepath.Join(configDir, "docspell-cli", "config.json"), nil } func runImportCommand(cfg config, password string, args []string) { options, err := parseImportOptions(args) if err != nil { fmt.Println("Error parsing flags:", err) os.Exit(1) } validateConfig(cfg) ctx := context.Background() client := NewDocspellClient(cfg.DocspellURL, defaultDocspellAuthHeader) if err := client.Login(ctx, cfg.User, password); err != nil { fmt.Println("Login failed:", err) os.Exit(0) } fmt.Println("Settings:") 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 { fmt.Println(" - UPLOAD files? no") fmt.Println(" files not existing in Docspell will NOT be uploaded and stay where they are.") } fmt.Println() fmt.Println() fmt.Println("Press 'ctrl+c' to cancel") time.Sleep(time.Second) for _, dsConsumedir := range cfg.ImportDirectories { fmt.Println() fmt.Println() fmt.Printf("Scanning folder '%s'\n", dsConsumedir) fmt.Println() err := filepath.Walk(dsConsumedir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } fmt.Println() fmt.Printf("%s:\n", info.Name()) fileExistsResponse, err := client.FileExists(ctx, path) if err != nil { fmt.Printf(" ERROR %v\n", err) return nil } if fileExistsResponse.Exists { err := handleExistingFile(ctx, client, cfg, fileExistsResponse) if err != nil { fmt.Println(" ERROR", err) } } else { fmt.Println(" Files does not exist, yet") if options.uploadMissing { fmt.Print(" ...uploading file..") uploadResult, err := client.UploadFile(ctx, path, UploadMeta{Multiple: true}) if err != nil { fmt.Printf("\n ERROR uploading: %v\n", err) return nil } if uploadResult.Success { fmt.Println(". done") } else { fmt.Printf("\n ERROR %s\n", uploadResult.Message) } } } return nil }) if err != nil { fmt.Printf("Error walking directory: %v\n", err) os.Exit(1) } } } 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(ctx context.Context, client *DocspellClient, cfg config, fileExistsResponse FileExistsResult) error { // File exists in Docspell items := fileExistsResponse.Items if len(items) == 0 { return fmt.Errorf("file exists response contained no items") } item := items[0] itemID := item.ID itemName := item.Name // Get item details itemDetails, err := client.GetItem(ctx, itemID) if err != nil { return fmt.Errorf("get item details: %w", err) } folder := "null" if itemDetails.Folder != nil { folder = itemDetails.Folder.Name } extension := filepath.Ext(fileExistsResponse.File)[1:] var corr string if itemDetails.CorrOrg != nil && itemDetails.CorrOrg.Name != "" { corr = itemDetails.CorrOrg.Name } else if itemDetails.CorrPerson != nil && itemDetails.CorrPerson.Name != "" { corr = itemDetails.CorrPerson.Name } fmt.Printf(" File already exists: %s\n", itemName) fmt.Printf(" URL: %s/app/item/%s\n", cfg.DocspellURL, itemID) state := item.State if state != "confirmed" { fmt.Println(" ... but is not confirmed yet - not doing anything.") return nil } itemDate := item.ItemDate if itemDate == 0 { fmt.Println(" ... but has no date - not doing anything.") return nil } date := time.Unix(itemDate/1000, 0) curDir := filepath.Join(cfg.ArchiveDirectory, folder, date.Format("2006/01")) if err := os.MkdirAll(curDir, 0755); err != nil { return fmt.Errorf("create directory: %w", err) } subfolder := fmt.Sprintf("%s %s - %s.%s", date.Format("20060102"), corr, itemName, extension) newPath := filepath.Join(curDir, subfolder) if err := MoveFile(fileExistsResponse.File, newPath); err != nil { return fmt.Errorf("move file: %w", err) } fmt.Printf(" ... moving to archive by date ('%s')\n", curDir) return nil } func validateConfig(cfg config) { if len(cfg.ImportDirectories) == 0 || cfg.ArchiveDirectory == "" { fmt.Println("FATAL Parameter missing") fmt.Printf(" import directories: %v\n", cfg.ImportDirectories) fmt.Printf(" archive directory: %s\n", cfg.ArchiveDirectory) os.Exit(-2) } if cfg.User == "" { fmt.Println("FATAL User is missing") os.Exit(-3) } } type DocspellEntity struct { ID string `json:"id"` Name string `json:"name"` }