Add read-only Docspell FUSE mount
This commit is contained in:
@@ -21,3 +21,29 @@ Das Programm:
|
|||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Erstellen Sie eine `docspell-import.json` Datei in Ihrem Home-Verzeichnis: [Beispiel-Konfiguration](./docspell-import-example.json)
|
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
|
||||||
|
<Docspell folder>/<year>/<month>/<attachment name>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
module git.javil.eu/jacob1123/docspell-import
|
module git.javil.eu/jacob1123/docspell-import
|
||||||
|
|
||||||
go 1.23.4
|
go 1.23.4
|
||||||
|
|
||||||
|
require bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.4.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -37,16 +37,26 @@ type DocspellItem struct {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("##################### START #####################")
|
fmt.Println("##################### START #####################")
|
||||||
fmt.Println(" Docspell Import - v0.2")
|
fmt.Println(" Docspell Import - v0.3")
|
||||||
fmt.Println(" by jacob1123")
|
fmt.Println(" by jacob1123")
|
||||||
fmt.Println(" (based on work by totti4ever) ")
|
fmt.Println(" (based on work by totti4ever) ")
|
||||||
fmt.Println("#################################################")
|
fmt.Println("#################################################")
|
||||||
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")
|
cfg, password := loadConfigAndPassword()
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// 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()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error getting user home directory:", err)
|
fmt.Println("Error getting user home directory:", err)
|
||||||
@@ -66,7 +76,6 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get password from command
|
|
||||||
cmd := exec.Command(cfg.PasswordCommand, cfg.PasswordCommandArgs...)
|
cmd := exec.Command(cfg.PasswordCommand, cfg.PasswordCommandArgs...)
|
||||||
password, err := cmd.Output()
|
password, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,10 +83,20 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
validateConfig(cfg)
|
||||||
|
|
||||||
// Login
|
loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password)
|
||||||
loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", strings.TrimSpace(string(password)))
|
|
||||||
if err := loginCmd.Run(); err != nil {
|
if err := loginCmd.Run(); err != nil {
|
||||||
fmt.Println("Login failed:", err)
|
fmt.Println("Login failed:", err)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -102,7 +121,7 @@ func main() {
|
|||||||
fmt.Printf("Scanning folder '%s'\n", dsConsumedir)
|
fmt.Printf("Scanning folder '%s'\n", dsConsumedir)
|
||||||
fmt.Println()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,7 +133,6 @@ func main() {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s:\n", info.Name())
|
fmt.Printf("%s:\n", info.Name())
|
||||||
|
|
||||||
// Check if file exists in Docspell
|
|
||||||
cmd := exec.Command("dsc", "-f", "json", "file-exists", path)
|
cmd := exec.Command("dsc", "-f", "json", "file-exists", path)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,7 +146,7 @@ func main() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if fileExistsResponse[0].Exists {
|
if len(fileExistsResponse) > 0 && fileExistsResponse[0].Exists {
|
||||||
err := handleExistingFile(cfg, fileExistsResponse[0])
|
err := handleExistingFile(cfg, fileExistsResponse[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(" ERROR", err)
|
fmt.Println(" ERROR", err)
|
||||||
|
|||||||
@@ -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] <mountpoint>", 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user