Use mount metadata for path lookups
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -35,6 +36,23 @@ type fileNode struct {
|
|||||||
data []byte
|
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 {
|
func runMountCommand(args []string, cfg config, password string) error {
|
||||||
fsFlags := flag.NewFlagSet("mount", flag.ExitOnError)
|
fsFlags := flag.NewFlagSet("mount", flag.ExitOnError)
|
||||||
query := fsFlags.String("query", cfg.MountQuery, "Docspell item query to expose")
|
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
|
return err
|
||||||
}
|
}
|
||||||
id := itemIDFromURLOrID(flags.Arg(0))
|
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+".*"))
|
matches, err := filepath.Glob(filepath.Join(mountPoint, "by-id", idShard(id), id+".*"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -134,44 +156,30 @@ func runGetLocalPathCommand(args []string, cfg config, password string) error {
|
|||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return fmt.Errorf("entry %s not found under mounted by-id tree", id)
|
return fmt.Errorf("entry %s not found under mounted by-id tree", id)
|
||||||
}
|
}
|
||||||
path := matches[0]
|
fmt.Println(matches[0])
|
||||||
if friendlyPath, err := friendlyLocalPath(mountPoint, path); err == nil && friendlyPath != "" {
|
|
||||||
path = friendlyPath
|
|
||||||
}
|
|
||||||
fmt.Println(path)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyLocalPath(mountPoint, byIDPath string) (string, error) {
|
func friendlyLocalPathForItemID(mountPoint, id string) (string, error) {
|
||||||
byIDInfo, err := os.Stat(byIDPath)
|
metadata, err := readMountMetadata(mountPoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
byIDRoot := filepath.Join(mountPoint, "by-id")
|
item, ok := metadata.Items[id]
|
||||||
var found string
|
if !ok || item.FriendlyPath == "" {
|
||||||
err = filepath.Walk(mountPoint, func(path string, info os.FileInfo, err error) error {
|
return "", nil
|
||||||
if err != nil || info == nil {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if path == byIDRoot || strings.HasPrefix(path, byIDRoot+string(os.PathSeparator)) {
|
return filepath.Join(mountPoint, filepath.FromSlash(item.FriendlyPath)), nil
|
||||||
if info.IsDir() {
|
}
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
func readMountMetadata(mountPoint string) (mountMetadata, error) {
|
||||||
return nil
|
var metadata mountMetadata
|
||||||
}
|
data, err := os.ReadFile(filepath.Join(mountPoint, mountMetadataFileName))
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if os.SameFile(byIDInfo, info) {
|
|
||||||
found = path
|
|
||||||
return filepath.SkipAll
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return metadata, err
|
||||||
}
|
}
|
||||||
return found, nil
|
err = json.Unmarshal(data, &metadata)
|
||||||
|
return metadata, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGetURLCommand(args []string, cfg config, password string) error {
|
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" {
|
if len(parts) >= 3 && parts[0] == "by-id" {
|
||||||
return strings.TrimSuffix(parts[2], filepath.Ext(parts[2])), nil
|
return strings.TrimSuffix(parts[2], filepath.Ext(parts[2])), nil
|
||||||
}
|
}
|
||||||
info, err := os.Stat(abs)
|
metadata, err := readMountMetadata(mountPoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var found string
|
rel = filepath.ToSlash(filepath.Clean(rel))
|
||||||
err = filepath.Walk(filepath.Join(mountPoint, "by-id"), func(p string, fi os.FileInfo, err error) error {
|
for id, item := range metadata.Items {
|
||||||
if err != nil || fi == nil || fi.IsDir() {
|
if filepath.ToSlash(filepath.Clean(filepath.FromSlash(item.FriendlyPath))) == rel {
|
||||||
return nil
|
return id, nil
|
||||||
}
|
}
|
||||||
if os.SameFile(info, fi) {
|
|
||||||
found = strings.TrimSuffix(filepath.Base(p), filepath.Ext(p))
|
|
||||||
return filepath.SkipAll
|
|
||||||
}
|
}
|
||||||
return nil
|
return "", fmt.Errorf("could not map mounted path to item id")
|
||||||
})
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchItem) *dirNode {
|
func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchItem) *dirNode {
|
||||||
root := &dirNode{name: "", children: map[string]fs.Node{}}
|
root := &dirNode{name: "", children: map[string]fs.Node{}}
|
||||||
byID := &dirNode{name: "by-id", children: map[string]fs.Node{}}
|
byID := &dirNode{name: "by-id", children: map[string]fs.Node{}}
|
||||||
root.children["by-id"] = byID
|
root.children["by-id"] = byID
|
||||||
|
metadata := mountMetadata{Items: map[string]mountedItemMetadata{}}
|
||||||
|
metadataModTime := time.Now()
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
folderName := "No Folder"
|
folderName := "No Folder"
|
||||||
if item.Folder != nil && item.Folder.Name != "" {
|
if item.Folder != nil && item.Folder.Name != "" {
|
||||||
@@ -343,7 +342,8 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI
|
|||||||
modTime = time.UnixMilli(date)
|
modTime = time.UnixMilli(date)
|
||||||
yearMonth = modTime.Format("2006/01")
|
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)
|
base := sanitizeName(item.Name)
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = item.ID
|
base = item.ID
|
||||||
@@ -366,6 +366,7 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI
|
|||||||
name = uniqueName(dir.children, sanitizeName(name))
|
name = uniqueName(dir.children, sanitizeName(name))
|
||||||
node := &fileNode{name: name, attachmentID: att.ID, size: uint64(size), knownSize: size > 0, modTime: modTime, client: client}
|
node := &fileNode{name: name, attachmentID: att.ID, size: uint64(size), knownSize: size > 0, modTime: modTime, client: client}
|
||||||
dir.children[name] = node
|
dir.children[name] = node
|
||||||
|
friendlyRelPath := filepath.ToSlash(filepath.Join(append(sanitizePathParts(dirParts), name)...))
|
||||||
|
|
||||||
byIDName := item.ID + ext
|
byIDName := item.ID + ext
|
||||||
if len(item.Attachments) > 1 {
|
if len(item.Attachments) > 1 {
|
||||||
@@ -375,12 +376,35 @@ func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchI
|
|||||||
}
|
}
|
||||||
shard := idShard(item.ID)
|
shard := idShard(item.ID)
|
||||||
shardDir := ensureDir(byID, shard)
|
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
|
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 {
|
func idShard(id string) string {
|
||||||
id = sanitizeName(id)
|
id = sanitizeName(id)
|
||||||
if len(id) >= 2 {
|
if len(id) >= 2 {
|
||||||
@@ -491,6 +515,28 @@ func (f *fileNode) ReadAll(ctx context.Context) ([]byte, error) {
|
|||||||
return f.load(ctx)
|
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) {
|
func (f *fileNode) load(ctx context.Context) ([]byte, error) {
|
||||||
if f.data != nil {
|
if f.data != nil {
|
||||||
return f.data, nil
|
return f.data, nil
|
||||||
|
|||||||
+62
-35
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func TestBuildMountTreeDeduplicatesAttachmentNames(t *testing.T) {
|
||||||
items := []SearchItem{{
|
items := []SearchItem{{
|
||||||
ID: "item-1",
|
ID: "item-1",
|
||||||
@@ -121,50 +144,54 @@ func TestBuildMountTreeAddsByIDDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFriendlyLocalPathPrefersNonByIDPath(t *testing.T) {
|
func TestBuildMountTreeAddsMetadataLookup(t *testing.T) {
|
||||||
mountPath := t.TempDir()
|
items := []SearchItem{{
|
||||||
friendlyDir := filepath.Join(mountPath, "Bills", "2024", "07")
|
ID: "item-1",
|
||||||
byIDDir := filepath.Join(mountPath, "by-id", "it")
|
Name: "Invoice/July",
|
||||||
if err := os.MkdirAll(friendlyDir, 0755); err != nil {
|
Date: 1720396800000,
|
||||||
t.Fatal(err)
|
Folder: &DocspellEntity{
|
||||||
}
|
Name: "Bills/Private",
|
||||||
if err := os.MkdirAll(byIDDir, 0755); err != nil {
|
},
|
||||||
t.Fatal(err)
|
Attachments: []ItemAttachment{{ID: "att-1", Name: "invoice.pdf", Size: 123}},
|
||||||
}
|
}}
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := friendlyLocalPath(mountPath, byIDPath)
|
root := buildMountTree(context.Background(), &DocspellClient{}, items)
|
||||||
if err != nil {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != friendlyPath {
|
got := metadata.Items["item-1"]
|
||||||
t.Fatalf("expected friendly path %q, got %q", friendlyPath, got)
|
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()
|
mountPath := t.TempDir()
|
||||||
byIDDir := filepath.Join(mountPath, "by-id", "it")
|
metadata := mountMetadata{Items: map[string]mountedItemMetadata{
|
||||||
if err := os.MkdirAll(byIDDir, 0755); err != nil {
|
"item-1": {FriendlyPath: "Bills/2024/07/Invoice.pdf"},
|
||||||
t.Fatal(err)
|
}}
|
||||||
}
|
data, err := json.Marshal(metadata)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != "" {
|
if err := os.WriteFile(filepath.Join(mountPath, mountMetadataFileName), data, 0644); err != nil {
|
||||||
t.Fatalf("expected no friendly path, got %q", got)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user