Add read-only Docspell FUSE mount
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user