From 952d3d8532bc7500bb46b5165e21e45b7902724d Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Mon, 13 Jul 2026 22:58:59 +0200 Subject: [PATCH] Add read-only Docspell FUSE mount --- README.md | 26 ++++++ docspell_client.go | 198 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 + go.sum | 6 ++ main.go | 38 ++++++--- mount.go | 199 +++++++++++++++++++++++++++++++++++++++++++++ mount_test.go | 53 ++++++++++++ 7 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 docspell_client.go create mode 100644 go.sum create mode 100644 mount.go create mode 100644 mount_test.go diff --git a/README.md b/README.md index a973ca2..e043784 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,29 @@ Das Programm: ## Konfiguration Erstellen Sie eine `docspell-import.json` Datei in Ihrem Home-Verzeichnis: [Beispiel-Konfiguration](./docspell-import-example.json) + +## Read-only FUSE mount + +The `mount` subcommand exposes a Docspell collection as a read-only filesystem. +It uses the same `~/docspell-import.json` configuration and password command as the import command. + +```bash +mkdir -p ~/mnt/docspell +docspell-import mount --query '*' ~/mnt/docspell +# unmount when done +fusermount -u ~/mnt/docspell +``` + +Mounted files are grouped as: + +```text +/// +``` + +Options: + +- `--query`: Docspell item query to expose, default `*`. +- `--limit`: number of items fetched per API request, default `1000`. +- `--auth-header`: token header, default `X-Docspell-Auth`. + +The mount is read-only. File contents are downloaded lazily from `/api/v1/sec/attachment/{id}` when read. diff --git a/docspell_client.go b/docspell_client.go new file mode 100644 index 0000000..f55ec2b --- /dev/null +++ b/docspell_client.go @@ -0,0 +1,198 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +const defaultDocspellAuthHeader = "X-Docspell-Auth" + +type DocspellClient struct { + BaseURL string + Token string + AuthHeader string + HTTPClient *http.Client +} + +type loginResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Token string `json:"token"` + RequireSecondFactor bool `json:"requireSecondFactor"` +} + +type searchResponse struct { + Groups []struct { + Items []SearchItem `json:"items"` + } `json:"groups"` + Limit int `json:"limit"` + Offset int `json:"offset"` + LimitCapped bool `json:"limitCapped"` +} + +type SearchItem struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Date int64 `json:"date"` + ItemDate int64 `json:"itemDate"` + Attachments []ItemAttachment `json:"attachments"` + Folder *DocspellEntity `json:"folder"` + CorrOrg *DocspellEntity `json:"corrOrg"` + CorrPerson *DocspellEntity `json:"corrPerson"` + Tags []DocspellTag `json:"tags"` +} + +type ItemAttachment struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + Converted bool `json:"converted"` + Position int `json:"position"` +} + +type DocspellTag struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Created int64 `json:"created"` +} + +func NewDocspellClient(baseURL, authHeader string) *DocspellClient { + if authHeader == "" { + authHeader = defaultDocspellAuthHeader + } + return &DocspellClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + AuthHeader: authHeader, + HTTPClient: &http.Client{Timeout: 60 * time.Second}, + } +} + +func (c *DocspellClient) Login(ctx context.Context, account, password string) error { + var out loginResponse + err := c.doJSON(ctx, http.MethodPost, "/api/v1/open/auth/login", map[string]any{ + "account": account, + "password": password, + }, &out) + if err != nil { + return err + } + if !out.Success { + return fmt.Errorf("login failed: %s", out.Message) + } + if out.RequireSecondFactor { + return fmt.Errorf("login requires a second factor; token-based mounting is not implemented") + } + if out.Token == "" { + return fmt.Errorf("login response did not include a token") + } + c.Token = out.Token + return nil +} + +func (c *DocspellClient) SearchItems(ctx context.Context, query string, limit int) ([]SearchItem, error) { + if limit <= 0 { + limit = 1000 + } + var all []SearchItem + for offset := 0; ; offset += limit { + var out searchResponse + err := c.doJSON(ctx, http.MethodPost, "/api/v1/sec/item/search", map[string]any{ + "query": query, + "offset": offset, + "limit": limit, + "withDetails": true, + "searchMode": "normal", + }, &out) + if err != nil { + return nil, err + } + count := 0 + for _, group := range out.Groups { + all = append(all, group.Items...) + count += len(group.Items) + } + if count == 0 || count < limit { + return all, nil + } + } +} + +func (c *DocspellClient) GetItem(ctx context.Context, id string) (*SearchItem, error) { + var out SearchItem + if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sec/item/"+url.PathEscape(id), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *DocspellClient) DownloadAttachment(ctx context.Context, id string) ([]byte, error) { + return c.doBytes(ctx, http.MethodGet, "/api/v1/sec/attachment/"+url.PathEscape(id), nil) +} + +func (c *DocspellClient) doJSON(ctx context.Context, method, apiPath string, in any, out any) error { + var body io.Reader + if in != nil { + buf, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, c.url(apiPath), body) + if err != nil { + return err + } + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + data, err := c.do(req) + if err != nil { + return err + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) +} + +func (c *DocspellClient) doBytes(ctx context.Context, method, apiPath string, body io.Reader) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, c.url(apiPath), body) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *DocspellClient) do(req *http.Request) ([]byte, error) { + if c.Token != "" { + req.Header.Set(c.AuthHeader, c.Token) + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s %s: %s: %s", req.Method, req.URL, resp.Status, strings.TrimSpace(string(data))) + } + return data, nil +} + +func (c *DocspellClient) url(apiPath string) string { + return c.BaseURL + path.Clean("/"+apiPath) +} diff --git a/go.mod b/go.mod index aab749c..c79664c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module git.javil.eu/jacob1123/docspell-import go 1.23.4 + +require bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 + +require golang.org/x/sys v0.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8bbf2cc --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ= +bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY= +github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= +github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go index d1e45ad..ef0d182 100644 --- a/main.go +++ b/main.go @@ -37,16 +37,26 @@ type DocspellItem struct { func main() { fmt.Println("##################### START #####################") - fmt.Println(" Docspell Import - v0.2") + fmt.Println(" Docspell Import - v0.3") fmt.Println(" by jacob1123") fmt.Println(" (based on work by totti4ever) ") fmt.Println("#################################################") fmt.Println() - uploadMissing := flag.Bool("upload-missing", os.Getenv("DS_CC_UPLOAD_MISSING") == "true", "true, to upload files to docspell that do not yet exist there") - flag.Parse() + cfg, password := loadConfigAndPassword() - // Read config from user profile + if len(os.Args) > 1 && os.Args[1] == "mount" { + if err := runMountCommand(os.Args[2:], cfg, password); err != nil { + fmt.Fprintln(os.Stderr, "mount failed:", err) + os.Exit(1) + } + return + } + + runImportCommand(cfg, password, os.Args[1:]) +} + +func loadConfigAndPassword() (config, string) { homeDir, err := os.UserHomeDir() if err != nil { fmt.Println("Error getting user home directory:", err) @@ -66,7 +76,6 @@ func main() { os.Exit(1) } - // Get password from command cmd := exec.Command(cfg.PasswordCommand, cfg.PasswordCommandArgs...) password, err := cmd.Output() if err != nil { @@ -74,10 +83,20 @@ func main() { os.Exit(1) } + return cfg, strings.TrimSpace(string(password)) +} + +func runImportCommand(cfg config, password string, args []string) { + importFlags := flag.NewFlagSet("import", flag.ExitOnError) + uploadMissing := importFlags.Bool("upload-missing", os.Getenv("DS_CC_UPLOAD_MISSING") == "true", "true, to upload files to docspell that do not yet exist there") + if err := importFlags.Parse(args); err != nil { + fmt.Println("Error parsing flags:", err) + os.Exit(1) + } + validateConfig(cfg) - // Login - loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", strings.TrimSpace(string(password))) + loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password) if err := loginCmd.Run(); err != nil { fmt.Println("Login failed:", err) os.Exit(0) @@ -102,7 +121,7 @@ func main() { fmt.Printf("Scanning folder '%s'\n", dsConsumedir) fmt.Println() - err = filepath.Walk(dsConsumedir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(dsConsumedir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -114,7 +133,6 @@ func main() { fmt.Println() fmt.Printf("%s:\n", info.Name()) - // Check if file exists in Docspell cmd := exec.Command("dsc", "-f", "json", "file-exists", path) output, err := cmd.Output() if err != nil { @@ -128,7 +146,7 @@ func main() { return nil } - if fileExistsResponse[0].Exists { + if len(fileExistsResponse) > 0 && fileExistsResponse[0].Exists { err := handleExistingFile(cfg, fileExistsResponse[0]) if err != nil { fmt.Println(" ERROR", err) diff --git a/mount.go b/mount.go new file mode 100644 index 0000000..0e0b231 --- /dev/null +++ b/mount.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "bazil.org/fuse" + "bazil.org/fuse/fs" +) + +type docspellFS struct { + root *dirNode +} + +type dirNode struct { + name string + children map[string]fs.Node +} + +type fileNode struct { + name string + attachmentID string + size uint64 + modTime time.Time + client *DocspellClient + data []byte +} + +func runMountCommand(args []string, cfg config, password string) error { + fsFlags := flag.NewFlagSet("mount", flag.ExitOnError) + query := fsFlags.String("query", "*", "Docspell item query to expose") + limit := fsFlags.Int("limit", 1000, "number of items to fetch per API request") + authHeader := fsFlags.String("auth-header", defaultDocspellAuthHeader, "HTTP header used for the Docspell auth token") + if err := fsFlags.Parse(args); err != nil { + return err + } + if fsFlags.NArg() != 1 { + return fmt.Errorf("usage: %s mount [flags] ", filepath.Base(os.Args[0])) + } + + client := NewDocspellClient(cfg.DocspellURL, *authHeader) + if err := client.Login(context.Background(), cfg.User, password); err != nil { + return err + } + items, err := client.SearchItems(context.Background(), *query, *limit) + if err != nil { + return fmt.Errorf("search items: %w", err) + } + root := buildMountTree(client, items) + + conn, err := fuse.Mount( + fsFlags.Arg(0), + fuse.FSName("docspell"), + fuse.Subtype("docspell-import"), + fuse.ReadOnly(), + ) + if err != nil { + return err + } + defer conn.Close() + + fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), fsFlags.Arg(0), fsFlags.Arg(0)) + return fs.Serve(conn, &docspellFS{root: root}) +} + +func buildMountTree(client *DocspellClient, items []SearchItem) *dirNode { + root := &dirNode{name: "", children: map[string]fs.Node{}} + for _, item := range items { + folderName := "No Folder" + if item.Folder != nil && item.Folder.Name != "" { + folderName = item.Folder.Name + } + date := item.Date + if date == 0 { + date = item.ItemDate + } + yearMonth := "No Date" + modTime := time.Now() + if date > 0 { + modTime = time.UnixMilli(date) + yearMonth = modTime.Format("2006/01") + } + dir := ensureDir(root, folderName, strings.Split(yearMonth, "/")...) + base := sanitizeName(item.Name) + if base == "" { + base = item.ID + } + for i, att := range item.Attachments { + name := sanitizeName(att.Name) + if name == "" { + name = fmt.Sprintf("%s-%d.pdf", base, i+1) + } + if len(item.Attachments) > 1 { + name = fmt.Sprintf("%s - %02d - %s", base, i+1, name) + } + name = uniqueName(dir.children, name) + dir.children[name] = &fileNode{name: name, attachmentID: att.ID, size: uint64(att.Size), modTime: modTime, client: client} + } + } + return root +} + +func ensureDir(root *dirNode, first string, rest ...string) *dirNode { + cur := root + parts := append([]string{first}, rest...) + for _, part := range parts { + part = sanitizeName(part) + if part == "" { + part = "_" + } + next, ok := cur.children[part].(*dirNode) + if !ok { + next = &dirNode{name: part, children: map[string]fs.Node{}} + cur.children[part] = next + } + cur = next + } + return cur +} + +func sanitizeName(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\x00", "_") + return s +} + +func uniqueName(children map[string]fs.Node, name string) string { + if _, ok := children[name]; !ok { + return name + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s (%d)%s", base, i, ext) + if _, ok := children[candidate]; !ok { + return candidate + } + } +} + +func (f *docspellFS) Root() (fs.Node, error) { return f.root, nil } + +func (d *dirNode) Attr(ctx context.Context, a *fuse.Attr) error { + a.Mode = os.ModeDir | 0555 + return nil +} + +func (d *dirNode) Lookup(ctx context.Context, name string) (fs.Node, error) { + child, ok := d.children[name] + if !ok { + return nil, fuse.ENOENT + } + return child, nil +} + +func (d *dirNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { + names := make([]string, 0, len(d.children)) + for name := range d.children { + names = append(names, name) + } + sort.Strings(names) + entries := make([]fuse.Dirent, 0, len(names)) + for _, name := range names { + typ := fuse.DT_File + if _, ok := d.children[name].(*dirNode); ok { + typ = fuse.DT_Dir + } + entries = append(entries, fuse.Dirent{Name: name, Type: typ}) + } + return entries, nil +} + +func (f *fileNode) Attr(ctx context.Context, a *fuse.Attr) error { + a.Mode = 0444 + a.Size = f.size + a.Mtime = f.modTime + a.Ctime = f.modTime + return nil +} + +func (f *fileNode) ReadAll(ctx context.Context) ([]byte, error) { + if f.data != nil { + return f.data, nil + } + data, err := f.client.DownloadAttachment(ctx, f.attachmentID) + if err != nil { + return nil, err + } + f.data = data + f.size = uint64(len(data)) + return data, nil +} diff --git a/mount_test.go b/mount_test.go new file mode 100644 index 0000000..eb31526 --- /dev/null +++ b/mount_test.go @@ -0,0 +1,53 @@ +package main + +import "testing" + +func TestBuildMountTreeGroupsByFolderAndDate(t *testing.T) { + items := []SearchItem{{ + ID: "item-1", + Name: "Invoice/July", + Date: 1720396800000, + Folder: &DocspellEntity{ + Name: "Bills/Private", + }, + Attachments: []ItemAttachment{{ID: "att-1", Name: "invoice.pdf", Size: 123}}, + }} + + root := buildMountTree(&DocspellClient{}, items) + folder, ok := root.children["Bills_Private"].(*dirNode) + if !ok { + t.Fatalf("expected sanitized folder directory, got %#v", root.children) + } + year, ok := folder.children["2024"].(*dirNode) + if !ok { + t.Fatalf("expected year directory, got %#v", folder.children) + } + month, ok := year.children["07"].(*dirNode) + if !ok { + t.Fatalf("expected month directory, got %#v", year.children) + } + if _, ok := month.children["invoice.pdf"].(*fileNode); !ok { + t.Fatalf("expected attachment file, got %#v", month.children) + } +} + +func TestBuildMountTreeDeduplicatesAttachmentNames(t *testing.T) { + items := []SearchItem{{ + ID: "item-1", + Name: "Invoice", + Attachments: []ItemAttachment{ + {ID: "att-1", Name: "same.pdf"}, + {ID: "att-2", Name: "same.pdf"}, + }, + }} + + root := buildMountTree(&DocspellClient{}, items) + folder := root.children["No Folder"].(*dirNode) + date := folder.children["No Date"].(*dirNode) + if _, ok := date.children["Invoice - 01 - same.pdf"]; !ok { + t.Fatalf("expected first unique name, got %#v", date.children) + } + if _, ok := date.children["Invoice - 02 - same.pdf"]; !ok { + t.Fatalf("expected second unique name, got %#v", date.children) + } +}