Replace dsc import calls with Docspell API
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user