Use mount metadata for path lookups

This commit is contained in:
Jan Bader
2026-07-19 23:17:38 +02:00
parent 4b96db068a
commit ce1f9290b8
2 changed files with 158 additions and 85 deletions
+96 -50
View File
@@ -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