Replace dsc import calls with Docspell API

This commit is contained in:
Jan Bader
2026-07-20 20:48:08 +02:00
parent 608dc99c45
commit c4a2e9d085
4 changed files with 229 additions and 46 deletions
+2 -3
View File
@@ -5,7 +5,7 @@ A small command-line helper for managing Docspell import folders, archiving file
## Features ## Features
- Scan one or more configured import directories. - 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. - 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. - Preserve useful archive structure by Docspell folder and item date.
- Optionally upload files that do not yet exist in Docspell. - 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 ## Requirements
- [Docspell Command Line Client (`dsc`)](https://docspell.org/docs/tools/cli/) for the import/archive command.
- A running Docspell instance. - A running Docspell instance.
- A password command, for example [`pass`](https://www.passwordstore.org/), that prints the Docspell password to stdout. - 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. - FUSE support for the `mount` command, including `fusermount` or `fusermount3` for unmounting.
@@ -64,7 +63,7 @@ Run the `import` subcommand:
docspell-cli import 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. 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.
+102
View File
@@ -3,12 +3,17 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/textproto"
"net/url" "net/url"
"os"
"path" "path"
"path/filepath"
"strings" "strings"
"time" "time"
) )
@@ -60,6 +65,23 @@ type ItemAttachment struct {
Position int `json:"position"` 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 { type DocspellTag struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -137,6 +159,86 @@ func (c *DocspellClient) GetItem(ctx context.Context, id string) (*SearchItem, e
return &out, nil 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.url("/api/v1/sec/attachment/"+url.PathEscape(id)), nil)
if err != nil { if err != nil {
+105
View File
@@ -2,10 +2,16 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strconv" "strconv"
"strings"
"testing" "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)
}
}
+20 -43
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -35,7 +36,7 @@ type DocspellItem struct {
Direction string `json:"direction"` Direction string `json:"direction"`
State string `json:"state"` State string `json:"state"`
Created int64 `json:"created"` Created int64 `json:"created"`
ItemDate int64 `json:"item_date"` ItemDate int64 `json:"itemDate"`
} }
func main() { func main() {
@@ -164,8 +165,9 @@ func runImportCommand(cfg config, password string, args []string) {
validateConfig(cfg) validateConfig(cfg)
loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password) ctx := context.Background()
if err := loginCmd.Run(); err != nil { client := NewDocspellClient(cfg.DocspellURL, defaultDocspellAuthHeader)
if err := client.Login(ctx, cfg.User, password); err != nil {
fmt.Println("Login failed:", err) fmt.Println("Login failed:", err)
os.Exit(0) os.Exit(0)
} }
@@ -201,21 +203,14 @@ func runImportCommand(cfg config, password string, args []string) {
fmt.Println() fmt.Println()
fmt.Printf("%s:\n", info.Name()) fmt.Printf("%s:\n", info.Name())
cmd := exec.Command("dsc", "-f", "json", "file-exists", path) fileExistsResponse, err := client.FileExists(ctx, path)
output, err := cmd.Output()
if err != nil { if err != nil {
fmt.Printf(" ERROR %v\n", err) fmt.Printf(" ERROR %v\n", err)
return nil return nil
} }
var fileExistsResponse []FileExistsResult if fileExistsResponse.Exists {
if err := json.Unmarshal(output, &fileExistsResponse); err != nil { err := handleExistingFile(ctx, client, cfg, fileExistsResponse)
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 { if err != nil {
fmt.Println(" ERROR", err) fmt.Println(" ERROR", err)
} }
@@ -223,23 +218,15 @@ func runImportCommand(cfg config, password string, args []string) {
fmt.Println(" Files does not exist, yet") fmt.Println(" Files does not exist, yet")
if options.uploadMissing { if options.uploadMissing {
fmt.Print(" ...uploading file..") fmt.Print(" ...uploading file..")
cmd = exec.Command("dsc", "-f", "json", "upload", path) uploadResult, err := client.UploadFile(ctx, path, UploadMeta{Multiple: true})
output, err := cmd.Output()
if err != nil { if err != nil {
fmt.Printf("\n ERROR uploading: %v\n", err) fmt.Printf("\n ERROR uploading: %v\n", err)
return nil return nil
} }
if uploadResult.Success {
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") fmt.Println(". done")
} else { } 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) 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 // File exists in Docspell
items := fileExistsResponse.Items items := fileExistsResponse.Items
if len(items) == 0 {
return fmt.Errorf("file exists response contained no items")
}
item := items[0] item := items[0]
itemID := item.ID itemID := item.ID
itemName := item.Name itemName := item.Name
// Get item details // Get item details
cmd := exec.Command("dsc", "-f", "json", "item", "get", itemID) itemDetails, err := client.GetItem(ctx, itemID)
output, err := cmd.Output()
if err != nil { if err != nil {
return fmt.Errorf("get item details: %w", err) 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" folder := "null"
if itemDetails.Folder != nil { if itemDetails.Folder != nil {
folder = itemDetails.Folder.Name folder = itemDetails.Folder.Name
@@ -294,10 +278,10 @@ func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error {
extension := filepath.Ext(fileExistsResponse.File)[1:] extension := filepath.Ext(fileExistsResponse.File)[1:]
var corr string var corr string
if itemDetails.CorrespondingOrganisation != nil && itemDetails.CorrespondingOrganisation.Name != "" { if itemDetails.CorrOrg != nil && itemDetails.CorrOrg.Name != "" {
corr = itemDetails.CorrespondingOrganisation.Name corr = itemDetails.CorrOrg.Name
} else if itemDetails.CorrespondingPerson != nil && itemDetails.CorrespondingPerson.Name != "" { } else if itemDetails.CorrPerson != nil && itemDetails.CorrPerson.Name != "" {
corr = itemDetails.CorrespondingPerson.Name corr = itemDetails.CorrPerson.Name
} }
fmt.Printf(" File already exists: %s\n", itemName) fmt.Printf(" File already exists: %s\n", itemName)
fmt.Printf(" URL: %s/app/item/%s\n", cfg.DocspellURL, itemID) 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 { type DocspellEntity struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`