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] ", 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), knownSize: att.Size > 0, 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 if f.knownSize { a.Size = f.size } a.Mtime = f.modTime a.Ctime = f.modTime 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 }