Files
docspell-cli/mount.go
T
2026-07-13 23:12:32 +02:00

244 lines
5.9 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
"bazil.org/fuse/fuseutil"
)
type docspellFS struct {
root *dirNode
}
type dirNode struct {
name string
children map[string]fs.Node
}
type fileNode struct {
name string
attachmentID string
size uint64
knownSize bool
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(context.Background(), 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(ctx context.Context, client *DocspellClient, items []SearchItem) *dirNode {
root := &dirNode{name: "", children: map[string]fs.Node{}}
byID := &dirNode{name: "by-id", children: map[string]fs.Node{}}
root.children["by-id"] = byID
for _, item := range items {
if full, err := client.GetItem(ctx, item.ID); err == nil {
item = *full
}
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 {
ext := filepath.Ext(att.Name)
if ext == "" {
ext = ".pdf"
}
name := base + ext
if len(item.Attachments) > 1 {
name = fmt.Sprintf("%s - %02d%s", base, i+1, ext)
}
size := att.Size
if size <= 0 {
if headSize, err := client.AttachmentSize(ctx, att.ID); err == nil && headSize > 0 {
size = headSize
}
}
name = uniqueName(dir.children, sanitizeName(name))
node := &fileNode{name: name, attachmentID: att.ID, size: uint64(size), knownSize: size > 0, modTime: modTime, client: client}
dir.children[name] = node
byIDName := item.ID + ext
if len(item.Attachments) > 1 {
if i > 0 {
byIDName = fmt.Sprintf("%s-%d%s", item.ID, i+1, ext)
}
}
byID.children[uniqueName(byID.children, sanitizeName(byIDName))] = node
}
}
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
if f.knownSize {
a.Size = f.size
}
a.Mtime = f.modTime
a.Ctime = f.modTime
return nil
}
func (f *fileNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) error {
resp.Flags |= fuse.OpenDirectIO
return nil
}
func (f *fileNode) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
data, err := f.load(ctx)
if err != nil {
return err
}
fuseutil.HandleRead(req, resp, data)
return nil
}
func (f *fileNode) ReadAll(ctx context.Context) ([]byte, error) {
return f.load(ctx)
}
func (f *fileNode) load(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))
f.knownSize = true
return data, nil
}