diff --git a/README.md b/README.md index 18cdfdc..b214008 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A small command-line helper for managing Docspell import folders, archiving file ## Features - Scan one or more configured import directories. -- Check every file with `dsc file-exists` to see whether it is already known to Docspell. +- Check every file with Docspell's API to see whether it is already known to Docspell. - Move files that already exist in Docspell and are in the `confirmed` state into a local archive. - Preserve useful archive structure by Docspell folder and item date. - Optionally upload files that do not yet exist in Docspell. @@ -14,7 +14,6 @@ A small command-line helper for managing Docspell import folders, archiving file ## Requirements -- [Docspell Command Line Client (`dsc`)](https://docspell.org/docs/tools/cli/) for the import/archive command. - A running Docspell instance. - A password command, for example [`pass`](https://www.passwordstore.org/), that prints the Docspell password to stdout. - FUSE support for the `mount` command, including `fusermount` or `fusermount3` for unmounting. @@ -64,7 +63,7 @@ Run the `import` subcommand: docspell-cli import ``` -The command logs in with `dsc`, scans every configured import directory, and checks each file against Docspell. +The command logs in to Docspell via the REST API, scans every configured import directory, and checks each file against Docspell by SHA-256 checksum. If a file already exists in Docspell, the tool fetches the item details and prints the Docspell item URL. The local file is moved only when the Docspell item is `confirmed` and has an item date. Files that are still unconfirmed or missing a date are left untouched. diff --git a/docspell_client.go b/docspell_client.go index f24d392..92ac0aa 100644 --- a/docspell_client.go +++ b/docspell_client.go @@ -3,12 +3,17 @@ package main import ( "bytes" "context" + "crypto/sha256" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" + "os" "path" + "path/filepath" "strings" "time" ) @@ -60,6 +65,23 @@ type ItemAttachment struct { Position int `json:"position"` } +type BasicResult struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +type UploadMeta struct { + Multiple bool `json:"multiple"` + Direction string `json:"direction,omitempty"` + Folder string `json:"folder,omitempty"` + SkipDuplicates bool `json:"skipDuplicates,omitempty"` + Tags []string `json:"tags,omitempty"` + FileFilter string `json:"fileFilter,omitempty"` + Language string `json:"language,omitempty"` + AttachmentsOnly bool `json:"attachmentsOnly,omitempty"` + FlattenArchives bool `json:"flattenArchives,omitempty"` +} + type DocspellTag struct { ID string `json:"id"` Name string `json:"name"` @@ -137,6 +159,86 @@ func (c *DocspellClient) GetItem(ctx context.Context, id string) (*SearchItem, e return &out, nil } +func (c *DocspellClient) FileExists(ctx context.Context, filePath string) (FileExistsResult, error) { + var result FileExistsResult + hash, err := fileSHA256(filePath) + if err != nil { + return result, err + } + if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sec/checkfile/"+url.PathEscape(hash), nil, &result); err != nil { + return result, err + } + if abs, err := filepath.Abs(filePath); err == nil { + result.File = abs + } else { + result.File = filePath + } + return result, nil +} + +func (c *DocspellClient) UploadFile(ctx context.Context, filePath string, meta UploadMeta) (BasicResult, error) { + file, err := os.Open(filePath) + if err != nil { + return BasicResult{}, err + } + defer file.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + metaPart, err := writer.CreatePart(textPartHeader("meta", "application/json")) + if err != nil { + return BasicResult{}, err + } + if err := json.NewEncoder(metaPart).Encode(meta); err != nil { + return BasicResult{}, err + } + filePart, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return BasicResult{}, err + } + if _, err := io.Copy(filePart, file); err != nil { + return BasicResult{}, err + } + if err := writer.Close(); err != nil { + return BasicResult{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("/api/v1/sec/upload/item"), &body) + if err != nil { + return BasicResult{}, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + data, err := c.do(req) + if err != nil { + return BasicResult{}, err + } + var out BasicResult + if err := json.Unmarshal(data, &out); err != nil { + return BasicResult{}, err + } + return out, nil +} + +func fileSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +func textPartHeader(name, contentType string) textproto.MIMEHeader { + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, name)) + header.Set("Content-Type", contentType) + return header +} + func (c *DocspellClient) AttachmentSize(ctx context.Context, id string) (int64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.url("/api/v1/sec/attachment/"+url.PathEscape(id)), nil) if err != nil { diff --git a/docspell_client_test.go b/docspell_client_test.go index 1f6797e..227a11f 100644 --- a/docspell_client_test.go +++ b/docspell_client_test.go @@ -2,10 +2,16 @@ package main import ( "context" + "crypto/sha256" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" + "strings" "testing" ) @@ -57,3 +63,102 @@ func TestSearchItemsContinuesWhenServerCapsLimit(t *testing.T) { } } } + +func TestFileExistsUsesAuthenticatedChecksumEndpoint(t *testing.T) { + file := filepath.Join(t.TempDir(), "doc.txt") + content := []byte("hello docspell") + if err := os.WriteFile(file, content, 0644); err != nil { + t.Fatal(err) + } + wantHash := fmt.Sprintf("%x", sha256.Sum256(content)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/sec/checkfile/"+wantHash { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get(defaultDocspellAuthHeader) != "token-1" { + t.Fatalf("missing auth header: %q", r.Header.Get(defaultDocspellAuthHeader)) + } + _ = json.NewEncoder(w).Encode(FileExistsResult{ + Exists: true, + Items: []DocspellItem{{ID: "item-1", Name: "Invoice", State: "confirmed", ItemDate: 1720396800000}}, + }) + })) + defer server.Close() + + client := NewDocspellClient(server.URL, "") + client.Token = "token-1" + got, err := client.FileExists(context.Background(), file) + if err != nil { + t.Fatal(err) + } + if !got.Exists || got.Items[0].ID != "item-1" { + t.Fatalf("unexpected result: %#v", got) + } + if got.File == "" { + t.Fatal("expected local file path to be populated") + } +} + +func TestUploadFileUsesAuthenticatedMultipartEndpoint(t *testing.T) { + file := filepath.Join(t.TempDir(), "upload.txt") + if err := os.WriteFile(file, []byte("upload body"), 0644); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/sec/upload/item" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get(defaultDocspellAuthHeader) != "token-1" { + t.Fatalf("missing auth header: %q", r.Header.Get(defaultDocspellAuthHeader)) + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + t.Fatalf("expected multipart content type, got %q", r.Header.Get("Content-Type")) + } + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatal(err) + } + var meta UploadMeta + if err := json.Unmarshal([]byte(r.FormValue("meta")), &meta); err != nil { + t.Fatal(err) + } + if !meta.Multiple { + t.Fatalf("expected multiple upload meta, got %#v", meta) + } + parts := r.MultipartForm.File["file"] + if len(parts) != 1 || parts[0].Filename != "upload.txt" { + t.Fatalf("unexpected file parts: %#v", parts) + } + opened, err := parts[0].Open() + if err != nil { + t.Fatal(err) + } + defer opened.Close() + data, err := io.ReadAll(opened) + if err != nil { + t.Fatal(err) + } + if string(data) != "upload body" { + t.Fatalf("unexpected upload body %q", string(data)) + } + _ = json.NewEncoder(w).Encode(BasicResult{Success: true, Message: "ok"}) + })) + defer server.Close() + + client := NewDocspellClient(server.URL, "") + client.Token = "token-1" + got, err := client.UploadFile(context.Background(), file, UploadMeta{Multiple: true}) + if err != nil { + t.Fatal(err) + } + if !got.Success { + t.Fatalf("expected success, got %#v", got) + } +} diff --git a/main.go b/main.go index a8b5b00..c87913f 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "os" @@ -35,7 +36,7 @@ type DocspellItem struct { Direction string `json:"direction"` State string `json:"state"` Created int64 `json:"created"` - ItemDate int64 `json:"item_date"` + ItemDate int64 `json:"itemDate"` } func main() { @@ -164,8 +165,9 @@ func runImportCommand(cfg config, password string, args []string) { validateConfig(cfg) - loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password) - if err := loginCmd.Run(); err != nil { + 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) } @@ -201,21 +203,14 @@ func runImportCommand(cfg config, password string, args []string) { fmt.Println() fmt.Printf("%s:\n", info.Name()) - cmd := exec.Command("dsc", "-f", "json", "file-exists", path) - output, err := cmd.Output() + fileExistsResponse, err := client.FileExists(ctx, path) 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 fileExistsResponse.Exists { + err := handleExistingFile(ctx, client, cfg, fileExistsResponse) if err != nil { fmt.Println(" ERROR", err) } @@ -223,23 +218,15 @@ func runImportCommand(cfg config, password string, args []string) { 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() + uploadResult, err := client.UploadFile(ctx, path, UploadMeta{Multiple: true}) 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) { + if uploadResult.Success { fmt.Println(". done") } else { - fmt.Printf("\n ERROR %v\n", uploadResult) + fmt.Printf("\n ERROR %s\n", uploadResult.Message) } } } @@ -268,25 +255,22 @@ func parseImportOptions(args []string) (importOptions, error) { return options, parser.ParseArgs(args) } -func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error { +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 - cmd := exec.Command("dsc", "-f", "json", "item", "get", itemID) - output, err := cmd.Output() + itemDetails, err := client.GetItem(ctx, itemID) 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 @@ -294,10 +278,10 @@ func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error { 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 + 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) @@ -345,13 +329,6 @@ func validateConfig(cfg config) { } } -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"`