package main import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "bazil.org/fuse" "bazil.org/fuse/fs" "bazil.org/fuse/fuseutil" "github.com/integrii/flaggy" ) 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 } type staticFileNode struct { name string data []byte modTime time.Time } type mountMetadata struct { Items map[string]mountedItemMetadata `json:"items"` } type mountedItemMetadata struct { FriendlyPath string `json:"friendlyPath"` ByIDPath string `json:"byIDPath"` } const mountMetadataFileName = ".docspell-cli-metadata.json" func runMountCommand(args []string, cfg config, password string) error { options, err := parseMountOptions(args, cfg) if err != nil { return err } if options.mountPoint == "" { return fmt.Errorf("mount point missing: pass one or set MountPath in config") } if os.Getenv("DOCSPELL_CLI_MOUNT_FOREGROUND") != "1" { return startMountInBackground(args, options.mountPoint, false) } if err := os.MkdirAll(options.mountPoint, 0755); err != nil { return err } client := NewDocspellClient(cfg.DocspellURL, options.authHeader) if err := client.Login(context.Background(), cfg.User, password); err != nil { return err } items, err := client.SearchItems(context.Background(), options.query, options.limit) if err != nil { return fmt.Errorf("search items: %w", err) } root := buildMountTree(context.Background(), client, items) conn, err := fuse.Mount( options.mountPoint, fuse.FSName("docspell"), fuse.Subtype("docspell-cli"), 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), options.mountPoint, options.mountPoint) return fs.Serve(conn, &docspellFS{root: root}) } type mountOptions struct { query string limit int authHeader string mountPoint string } func parseMountOptions(args []string, cfg config) (mountOptions, error) { options := mountOptions{ query: cfg.MountQuery, limit: 1000, authHeader: defaultDocspellAuthHeader, mountPoint: cfg.MountPath, } parser := flaggy.NewParser("mount") parser.Description = "Expose Docspell search results as a read-only FUSE filesystem" parser.DisableShowVersionWithVersion() parser.ShowHelpOnUnexpected = true parser.String(&options.query, "", "query", "Docspell item query to expose") parser.Int(&options.limit, "", "limit", "number of items to fetch per API request") parser.String(&options.authHeader, "", "auth-header", "HTTP header used for the Docspell auth token") parser.AddPositionalValue(&options.mountPoint, "mountpoint", 1, false, "mount point, overriding mountPath from config") return options, parser.ParseArgs(args) } func startMountInBackground(args []string, mountPoint string, quiet bool) error { mountPoint = cleanMountPath(mountPoint) if isMounted(mountPoint) { if !quiet { fmt.Printf("Already mounted at %s\n", mountPoint) } return nil } cmd := exec.Command(os.Args[0], append([]string{"mount"}, args...)...) cmd.Env = append(os.Environ(), "DOCSPELL_CLI_MOUNT_FOREGROUND=1") logFile, err := mountLogFile() if err != nil { return err } defer logFile.Close() cmd.Stdout = logFile cmd.Stderr = logFile if err := cmd.Start(); err != nil { return err } if err := cmd.Process.Release(); err != nil { return err } if !quiet { fmt.Printf("Mounting %s in background. Unmount with: fusermount -u %s\n", mountPoint, mountPoint) } return nil } func runGetPathCommand(args []string, cfg config, password string) error { itemIDOrURL, err := parseSingleArgumentCommand("get-path", "item-id-or-url", "Docspell item ID or web URL", args) if err != nil { return err } mountPoint, err := ensureMounted(cfg, password) if err != nil { return err } id := itemIDFromURLOrID(itemIDOrURL) if path, err := friendlyLocalPathForItemID(mountPoint, id); err == nil && path != "" { fmt.Println(path) return nil } matches, err := filepath.Glob(filepath.Join(mountPoint, "by-id", idShard(id), id+".*")) if err != nil { return err } if len(matches) == 0 { return fmt.Errorf("entry %s not found under mounted by-id tree", id) } fmt.Println(matches[0]) return nil } func friendlyLocalPathForItemID(mountPoint, id string) (string, error) { metadata, err := readMountMetadata(mountPoint) if err != nil { return "", err } item, ok := metadata.Items[id] if !ok || item.FriendlyPath == "" { return "", nil } return filepath.Join(mountPoint, filepath.FromSlash(item.FriendlyPath)), nil } func readMountMetadata(mountPoint string) (mountMetadata, error) { var metadata mountMetadata data, err := os.ReadFile(filepath.Join(mountPoint, mountMetadataFileName)) if err != nil { return metadata, err } err = json.Unmarshal(data, &metadata) return metadata, err } func runGetURLCommand(args []string, cfg config, password string) error { id, err := parseSingleArgumentCommand("get-url", "local-path-or-item-id", "local mounted path, item ID, or item URL", args) if err != nil { return err } if strings.Contains(id, string(os.PathSeparator)) || strings.HasPrefix(id, ".") { id, err = itemIDFromLocalPath(id, cfg) if err != nil { return err } } fmt.Printf("%s/app/item/%s\n", strings.TrimRight(cfg.DocspellURL, "/"), itemIDFromURLOrID(id)) return nil } func parseSingleArgumentCommand(commandName, argName, argDescription string, args []string) (string, error) { var value string parser := flaggy.NewParser(commandName) parser.DisableShowVersionWithVersion() parser.ShowHelpOnUnexpected = true parser.AddPositionalValue(&value, argName, 1, true, argDescription) return value, parser.ParseArgs(args) } func ensureMounted(cfg config, password string) (string, error) { mountPoint := cleanMountPath(cfg.MountPath) if mountPoint == "" { return "", fmt.Errorf("MountPath missing in config") } if isMounted(mountPoint) { if isStaleMount(mountPoint) { _ = unmount(mountPoint) } else { return mountPoint, nil } } if isMounted(mountPoint) { return mountPoint, nil } if err := startMountInBackground([]string{mountPoint}, mountPoint, true); err != nil { return "", err } for i := 0; i < 300; i++ { if isMounted(mountPoint) { return mountPoint, nil } time.Sleep(100 * time.Millisecond) } return "", fmt.Errorf("mount did not become ready at %s; see %s", mountPoint, mountLogPath()) } func isStaleMount(mountPoint string) bool { _, err := os.ReadDir(mountPoint) return err != nil && strings.Contains(err.Error(), "transport endpoint is not connected") } func unmount(mountPoint string) error { if err := exec.Command("fusermount", "-u", mountPoint).Run(); err == nil { return nil } return exec.Command("fusermount3", "-u", mountPoint).Run() } func isMounted(mountPoint string) bool { data, err := os.ReadFile("/proc/mounts") if err != nil { return false } mountPoint = cleanMountPath(mountPoint) for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) >= 2 && cleanMountPath(fields[1]) == mountPoint { return true } } return false } func cleanMountPath(path string) string { if path == "" { return "" } abs, err := filepath.Abs(path) if err != nil { return filepath.Clean(path) } return filepath.Clean(abs) } func mountLogPath() string { cacheDir, err := os.UserCacheDir() if err != nil || cacheDir == "" { return filepath.Join(os.TempDir(), "docspell-cli-mount.log") } return filepath.Join(cacheDir, "docspell-cli", "mount.log") } func mountLogFile() (*os.File, error) { path := mountLogPath() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, err } return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) } func itemIDFromURLOrID(s string) string { s = strings.TrimSpace(s) if idx := strings.LastIndex(s, "/app/item/"); idx >= 0 { return strings.Trim(strings.TrimPrefix(s[idx:], "/app/item/"), "/") } return strings.Trim(s, "/") } func itemIDFromLocalPath(path string, cfg config) (string, error) { abs, err := filepath.Abs(path) if err != nil { return "", err } mountPoint, err := filepath.Abs(cfg.MountPath) if err != nil { return "", err } rel, err := filepath.Rel(mountPoint, abs) if err != nil || strings.HasPrefix(rel, "..") { return "", fmt.Errorf("path is not under MountPath %s", cfg.MountPath) } parts := strings.Split(rel, string(os.PathSeparator)) if len(parts) >= 3 && parts[0] == "by-id" { return strings.TrimSuffix(parts[2], filepath.Ext(parts[2])), nil } metadata, err := readMountMetadata(mountPoint) if err != nil { return "", err } rel = filepath.ToSlash(filepath.Clean(rel)) for id, item := range metadata.Items { if filepath.ToSlash(filepath.Clean(filepath.FromSlash(item.FriendlyPath))) == rel { return id, nil } } return "", fmt.Errorf("could not map mounted path to item id") } 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 metadata := mountMetadata{Items: map[string]mountedItemMetadata{}} metadataModTime := time.Now() 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") } dirParts := append([]string{folderName}, strings.Split(yearMonth, "/")...) dir := ensureDir(root, dirParts[0], dirParts[1:]...) 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 friendlyRelPath := filepath.ToSlash(filepath.Join(append(sanitizePathParts(dirParts), name)...)) byIDName := item.ID + ext if len(item.Attachments) > 1 { if i > 0 { byIDName = fmt.Sprintf("%s-%d%s", item.ID, i+1, ext) } } shard := idShard(item.ID) shardDir := ensureDir(byID, shard) byIDName = uniqueName(shardDir.children, sanitizeName(byIDName)) shardDir.children[byIDName] = node if i == 0 { metadata.Items[item.ID] = mountedItemMetadata{ FriendlyPath: friendlyRelPath, ByIDPath: filepath.ToSlash(filepath.Join("by-id", shard, byIDName)), } } } } metadataData, err := json.MarshalIndent(metadata, "", " ") if err == nil { root.children[mountMetadataFileName] = &staticFileNode{name: mountMetadataFileName, data: append(metadataData, '\n'), modTime: metadataModTime} } return root } func sanitizePathParts(parts []string) []string { sanitized := make([]string, len(parts)) for i, part := range parts { part = sanitizeName(part) if part == "" { part = "_" } sanitized[i] = part } return sanitized } func idShard(id string) string { id = sanitizeName(id) if len(id) >= 2 { return id[:2] } if id != "" { return id } return "__" } 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 *staticFileNode) Attr(ctx context.Context, a *fuse.Attr) error { a.Mode = 0444 a.Size = uint64(len(f.data)) a.Mtime = f.modTime a.Ctime = f.modTime return nil } func (f *staticFileNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) error { resp.Flags |= fuse.OpenDirectIO return nil } func (f *staticFileNode) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error { fuseutil.HandleRead(req, resp, f.data) return nil } func (f *staticFileNode) ReadAll(ctx context.Context) ([]byte, error) { return f.data, nil } 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 }