359 lines
10 KiB
Go
359 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"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:"item_date"`
|
|
}
|
|
|
|
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)
|
|
|
|
loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password)
|
|
if err := loginCmd.Run(); 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())
|
|
|
|
cmd := exec.Command("dsc", "-f", "json", "file-exists", path)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
fmt.Printf(" ERROR %v\n", err)
|
|
return nil
|
|
}
|
|
|
|
var fileExistsResponse []FileExistsResult
|
|
if err := json.Unmarshal(output, &fileExistsResponse); err != nil {
|
|
fmt.Printf(" ERROR parsing response: %v\n", err)
|
|
return nil
|
|
}
|
|
|
|
if len(fileExistsResponse) > 0 && fileExistsResponse[0].Exists {
|
|
err := handleExistingFile(cfg, fileExistsResponse[0])
|
|
if err != nil {
|
|
fmt.Println(" ERROR", err)
|
|
}
|
|
} else {
|
|
fmt.Println(" Files does not exist, yet")
|
|
if options.uploadMissing {
|
|
fmt.Print(" ...uploading file..")
|
|
cmd = exec.Command("dsc", "-f", "json", "upload", path)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
fmt.Printf("\n ERROR uploading: %v\n", err)
|
|
return nil
|
|
}
|
|
|
|
var uploadResult map[string]interface{}
|
|
if err := json.Unmarshal(output, &uploadResult); err != nil {
|
|
fmt.Printf("\n ERROR parsing upload result: %v\n", err)
|
|
return nil
|
|
}
|
|
|
|
if uploadResult["success"].(bool) {
|
|
fmt.Println(". done")
|
|
} else {
|
|
fmt.Printf("\n ERROR %v\n", uploadResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(cfg config, fileExistsResponse FileExistsResult) error {
|
|
// File exists in Docspell
|
|
items := fileExistsResponse.Items
|
|
item := items[0]
|
|
itemID := item.ID
|
|
itemName := item.Name
|
|
|
|
// Get item details
|
|
cmd := exec.Command("dsc", "-f", "json", "item", "get", itemID)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("get item details: %w", err)
|
|
}
|
|
|
|
var itemDetails DocspellItemDetails
|
|
if err := json.Unmarshal(output, &itemDetails); err != nil {
|
|
return fmt.Errorf("parse 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.CorrespondingOrganisation != nil && itemDetails.CorrespondingOrganisation.Name != "" {
|
|
corr = itemDetails.CorrespondingOrganisation.Name
|
|
} else if itemDetails.CorrespondingPerson != nil && itemDetails.CorrespondingPerson.Name != "" {
|
|
corr = itemDetails.CorrespondingPerson.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 DocspellItemDetails struct {
|
|
DocspellItem
|
|
CorrespondingOrganisation *DocspellEntity `json:"corr-org"`
|
|
CorrespondingPerson *DocspellEntity `json:"corr-person"`
|
|
Folder *DocspellEntity `json:"folder"`
|
|
}
|
|
|
|
type DocspellEntity struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|