From ce1f9290b8327d2ab6c76d065285f00533133fbb Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Sun, 19 Jul 2026 23:17:38 +0200 Subject: [PATCH] Use mount metadata for path lookups --- mount.go | 146 +++++++++++++++++++++++++++++++++----------------- mount_test.go | 97 +++++++++++++++++++++------------ 2 files changed, 158 insertions(+), 85 deletions(-) diff --git a/mount.go b/mount.go index 9f0d54b..7154679 100644 --- a/mount.go +++ b/mount.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "os" @@ -35,6 +36,23 @@ type fileNode struct { 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 { fsFlags := flag.NewFlagSet("mount", flag.ExitOnError) query := fsFlags.String("query", cfg.MountQuery, "Docspell item query to expose") @@ -127,6 +145,10 @@ func runGetLocalPathCommand(args []string, cfg config, password string) error { return err } id := itemIDFromURLOrID(flags.Arg(0)) + 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 @@ -134,44 +156,30 @@ func runGetLocalPathCommand(args []string, cfg config, password string) error { if len(matches) == 0 { return fmt.Errorf("entry %s not found under mounted by-id tree", id) } - path := matches[0] - if friendlyPath, err := friendlyLocalPath(mountPoint, path); err == nil && friendlyPath != "" { - path = friendlyPath - } - fmt.Println(path) + fmt.Println(matches[0]) return nil } -func friendlyLocalPath(mountPoint, byIDPath string) (string, error) { - byIDInfo, err := os.Stat(byIDPath) +func friendlyLocalPathForItemID(mountPoint, id string) (string, error) { + metadata, err := readMountMetadata(mountPoint) if err != nil { return "", err } - byIDRoot := filepath.Join(mountPoint, "by-id") - var found string - err = filepath.Walk(mountPoint, func(path string, info os.FileInfo, err error) error { - if err != nil || info == nil { - return nil - } - if path == byIDRoot || strings.HasPrefix(path, byIDRoot+string(os.PathSeparator)) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - if info.IsDir() { - return nil - } - if os.SameFile(byIDInfo, info) { - found = path - return filepath.SkipAll - } - return nil - }) - if err != nil { - return "", err + item, ok := metadata.Items[id] + if !ok || item.FriendlyPath == "" { + return "", nil } - return found, 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 { @@ -300,34 +308,25 @@ func itemIDFromLocalPath(path string, cfg config) (string, error) { if len(parts) >= 3 && parts[0] == "by-id" { return strings.TrimSuffix(parts[2], filepath.Ext(parts[2])), nil } - info, err := os.Stat(abs) + metadata, err := readMountMetadata(mountPoint) if err != nil { return "", err } - var found string - err = filepath.Walk(filepath.Join(mountPoint, "by-id"), func(p string, fi os.FileInfo, err error) error { - if err != nil || fi == nil || fi.IsDir() { - return nil + 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 } - if os.SameFile(info, fi) { - found = strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) - return filepath.SkipAll - } - return nil - }) - if err != nil { - return "", err } - if found == "" { - return "", fmt.Errorf("could not map mounted path to item id; try a by-id path") - } - return found, 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 != "" { @@ -343,7 +342,8 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI modTime = time.UnixMilli(date) yearMonth = modTime.Format("2006/01") } - dir := ensureDir(root, folderName, strings.Split(yearMonth, "/")...) + dirParts := append([]string{folderName}, strings.Split(yearMonth, "/")...) + dir := ensureDir(root, dirParts[0], dirParts[1:]...) base := sanitizeName(item.Name) if base == "" { base = item.ID @@ -366,6 +366,7 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI 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 { @@ -375,12 +376,35 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI } shard := idShard(item.ID) shardDir := ensureDir(byID, shard) - shardDir.children[uniqueName(shardDir.children, sanitizeName(byIDName))] = node + 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 { @@ -491,6 +515,28 @@ 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 diff --git a/mount_test.go b/mount_test.go index 050b5c7..28a52e1 100644 --- a/mount_test.go +++ b/mount_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "os" "path/filepath" "testing" @@ -59,6 +60,28 @@ func TestItemIDFromLocalByIDPath(t *testing.T) { } } +func TestItemIDFromLocalFriendlyPathUsesMetadata(t *testing.T) { + mountPath := t.TempDir() + metadata := mountMetadata{Items: map[string]mountedItemMetadata{ + "item-1": {FriendlyPath: "Bills/2024/07/Invoice.pdf"}, + }} + data, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mountPath, mountMetadataFileName), data, 0644); err != nil { + t.Fatal(err) + } + + got, err := itemIDFromLocalPath(filepath.Join(mountPath, "Bills", "2024", "07", "Invoice.pdf"), config{MountPath: mountPath}) + if err != nil { + t.Fatal(err) + } + if got != "item-1" { + t.Fatalf("expected item-1, got %q", got) + } +} + func TestBuildMountTreeDeduplicatesAttachmentNames(t *testing.T) { items := []SearchItem{{ ID: "item-1", @@ -121,50 +144,54 @@ func TestBuildMountTreeAddsByIDDirectory(t *testing.T) { } } -func TestFriendlyLocalPathPrefersNonByIDPath(t *testing.T) { - mountPath := t.TempDir() - friendlyDir := filepath.Join(mountPath, "Bills", "2024", "07") - byIDDir := filepath.Join(mountPath, "by-id", "it") - if err := os.MkdirAll(friendlyDir, 0755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(byIDDir, 0755); err != nil { - t.Fatal(err) - } - friendlyPath := filepath.Join(friendlyDir, "Invoice.pdf") - byIDPath := filepath.Join(byIDDir, "item-1.pdf") - if err := os.WriteFile(friendlyPath, []byte("pdf"), 0644); err != nil { - t.Fatal(err) - } - if err := os.Link(friendlyPath, byIDPath); err != nil { - t.Fatal(err) - } +func TestBuildMountTreeAddsMetadataLookup(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}}, + }} - got, err := friendlyLocalPath(mountPath, byIDPath) - if err != nil { + root := buildMountTree(context.Background(), &DocspellClient{}, items) + metadataNode, ok := root.children[mountMetadataFileName].(*staticFileNode) + if !ok { + t.Fatalf("expected metadata file, got %#v", root.children[mountMetadataFileName]) + } + var metadata mountMetadata + if err := json.Unmarshal(metadataNode.data, &metadata); err != nil { t.Fatal(err) } - if got != friendlyPath { - t.Fatalf("expected friendly path %q, got %q", friendlyPath, got) + got := metadata.Items["item-1"] + if got.FriendlyPath != "Bills_Private/2024/07/Invoice_July.pdf" { + t.Fatalf("expected friendly metadata path, got %q", got.FriendlyPath) + } + if got.ByIDPath != "by-id/it/item-1.pdf" { + t.Fatalf("expected by-id metadata path, got %q", got.ByIDPath) } } -func TestFriendlyLocalPathReturnsEmptyWhenNoFriendlyPathExists(t *testing.T) { +func TestFriendlyLocalPathForItemIDUsesMetadata(t *testing.T) { mountPath := t.TempDir() - byIDDir := filepath.Join(mountPath, "by-id", "it") - if err := os.MkdirAll(byIDDir, 0755); err != nil { - t.Fatal(err) - } - byIDPath := filepath.Join(byIDDir, "item-1.pdf") - if err := os.WriteFile(byIDPath, []byte("pdf"), 0644); err != nil { - t.Fatal(err) - } - - got, err := friendlyLocalPath(mountPath, byIDPath) + metadata := mountMetadata{Items: map[string]mountedItemMetadata{ + "item-1": {FriendlyPath: "Bills/2024/07/Invoice.pdf"}, + }} + data, err := json.Marshal(metadata) if err != nil { t.Fatal(err) } - if got != "" { - t.Fatalf("expected no friendly path, got %q", got) + if err := os.WriteFile(filepath.Join(mountPath, mountMetadataFileName), data, 0644); err != nil { + t.Fatal(err) + } + + got, err := friendlyLocalPathForItemID(mountPath, "item-1") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(mountPath, "Bills", "2024", "07", "Invoice.pdf") + if got != want { + t.Fatalf("expected %q, got %q", want, got) } }