feat: mount lazyly

This commit is contained in:
Jan Bader committed 2026-09-19 23:36:10 +02:00
1 parent cf391172a1
commit 3b91a26b2c
3 files changed
+381 -12

No files matched your search

+254 -7
View File
@@ -53,6 +53,8 @@ type mountedItemMetadata struct {
const mountMetadataFileName = ".docspell-cli-metadata.json"
const docspellIDCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
func runMountCommand(args []string, cfg config, password string) error {
options, err := parseMountOptions(args, cfg)
if err != nil {
@@ -72,11 +74,7 @@ func runMountCommand(args []string, cfg config, password string) error {
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)
root := buildLazyMountTree(client, options.query, options.limit)
conn, err := fuse.Mount(
options.mountPoint,
@@ -89,7 +87,7 @@ func runMountCommand(args []string, cfg config, password string) error {
}
defer conn.Close()
fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), options.mountPoint, options.mountPoint)
fmt.Printf("Mounted read-only at %s. Unmount with: fusermount -u %s\n", options.mountPoint, options.mountPoint)
return fs.Serve(conn, &docspellFS{root: root})
}
@@ -337,6 +335,255 @@ func itemIDFromLocalPath(path string, cfg config) (string, error) {
return "", fmt.Errorf("could not map mounted path to item id")
}
func buildLazyMountTree(client *DocspellClient, query string, limit int) *dirNode {
root := &dirNode{name: "", children: map[string]fs.Node{}}
currentYear := time.Now().Year()
for year := 1900; year <= currentYear+1; year++ {
name := fmt.Sprintf("%04d", year)
root.children[name] = &lazyYearNode{year: year, client: client, query: query, limit: limit}
}
root.children["No Date"] = &lazyItemDirNode{query: combineItemQueries(query, "not exist:date"), client: client, limit: limit}
root.children["by-id"] = &lazyByIDNode{client: client, query: query, limit: limit}
root.children[mountMetadataFileName] = &staticFileNode{
name: mountMetadataFileName,
data: []byte("{\"items\":{}}\n"),
modTime: time.Now(),
}
return root
}
type lazyYearNode struct {
year int
client *DocspellClient
query string
limit int
}
type lazyMonthNode struct {
query string
client *DocspellClient
limit int
}
type lazyItemDirNode struct {
query string
client *DocspellClient
limit int
}
type lazyByIDNode struct {
client *DocspellClient
query string
limit int
}
type lazyByIDShardNode struct {
query string
client *DocspellClient
limit int
}
func combineItemQueries(base, constraint string) string {
base = strings.TrimSpace(base)
if base == "" {
return constraint
}
return "(& " + base + " " + constraint + " )"
}
func (d *lazyYearNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *lazyYearNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
if len(name) != 2 || name < "01" || name > "12" {
return nil, fuse.ENOENT
}
return &lazyMonthNode{
query: combineItemQueries(d.query, fmt.Sprintf("date:%04d-%s", d.year, name)),
client: d.client,
limit: d.limit,
}, nil
}
func (d *lazyYearNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
entries := make([]fuse.Dirent, 0, 12)
for month := 1; month <= 12; month++ {
entries = append(entries, fuse.Dirent{Name: fmt.Sprintf("%02d", month), Type: fuse.DT_Dir})
}
return entries, nil
}
func (d *lazyMonthNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *lazyMonthNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
return (&lazyItemDirNode{query: d.query, client: d.client, limit: d.limit}).Lookup(ctx, name)
}
func (d *lazyMonthNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
return (&lazyItemDirNode{query: d.query, client: d.client, limit: d.limit}).ReadDirAll(ctx)
}
func (d *lazyItemDirNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *lazyItemDirNode) children(ctx context.Context) (map[string]fs.Node, error) {
items, err := d.client.SearchItems(ctx, d.query, d.limit)
if err != nil {
return nil, fmt.Errorf("search items: %w", err)
}
root := buildMountTree(ctx, d.client, items)
children := map[string]fs.Node{}
for name, child := range root.children {
if name != "by-id" {
collectMountFiles(children, child)
}
}
return children, nil
}
func collectMountFiles(children map[string]fs.Node, node fs.Node) {
dir, ok := node.(*dirNode)
if !ok {
file, ok := node.(*fileNode)
if ok {
name := file.name
for i := 2; ; i++ {
if _, exists := children[name]; !exists {
children[name] = file
break
}
ext := filepath.Ext(file.name)
name = fmt.Sprintf("%s (%d)%s", strings.TrimSuffix(file.name, ext), i, ext)
}
}
return
}
for _, child := range dir.children {
collectMountFiles(children, child)
}
}
func (d *lazyItemDirNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
children, err := d.children(ctx)
if err != nil {
return nil, err
}
child, ok := children[name]
if !ok {
return nil, fuse.ENOENT
}
return child, nil
}
func (d *lazyItemDirNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
children, err := d.children(ctx)
if err != nil {
return nil, err
}
return dirEntries(children), nil
}
func (d *lazyByIDNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *lazyByIDNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
if len(name) != 2 {
return nil, fuse.ENOENT
}
return &lazyByIDShardNode{
query: combineItemQueries(d.query, "id:"+name+"*"),
client: d.client,
limit: d.limit,
}, nil
}
func (d *lazyByIDNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
entries := make([]fuse.Dirent, 0, len(docspellIDCharacters)*len(docspellIDCharacters))
for _, first := range docspellIDCharacters {
for _, second := range docspellIDCharacters {
entries = append(entries, fuse.Dirent{Name: string([]rune{first, second}), Type: fuse.DT_Dir})
}
}
return entries, nil
}
func (d *lazyByIDShardNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *lazyByIDShardNode) children(ctx context.Context) (map[string]fs.Node, error) {
items, err := d.client.SearchItems(ctx, d.query, d.limit)
if err != nil {
return nil, fmt.Errorf("search items: %w", err)
}
root := buildMountTree(ctx, d.client, items)
byID, ok := root.children["by-id"].(*dirNode)
if !ok {
return map[string]fs.Node{}, nil
}
for _, child := range byID.children {
if shard, ok := child.(*dirNode); ok {
return shard.children, nil
}
}
return map[string]fs.Node{}, nil
}
func (d *lazyByIDShardNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
children, err := d.children(ctx)
if err != nil {
return nil, err
}
child, ok := children[name]
if !ok {
return nil, fuse.ENOENT
}
return child, nil
}
func (d *lazyByIDShardNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
children, err := d.children(ctx)
if err != nil {
return nil, err
}
return dirEntries(children), nil
}
func dirEntries(children map[string]fs.Node) []fuse.Dirent {
names := make([]string, 0, len(children))
for name := range children {
names = append(names, name)
}
sort.Strings(names)
entries := make([]fuse.Dirent, 0, len(names))
for _, name := range names {
typ := fuse.DT_File
if isDirectoryNode(children[name]) {
typ = fuse.DT_Dir
}
entries = append(entries, fuse.Dirent{Name: name, Type: typ})
}
return entries
}
func isDirectoryNode(node fs.Node) bool {
switch node.(type) {
case *dirNode, *lazyYearNode, *lazyMonthNode, *lazyItemDirNode, *lazyByIDNode, *lazyByIDShardNode:
return true
}
return false
}
// buildMountTree converts the pure item tree produced by buildItemTree into the
// FUSE node tree served by the mount command. A single *fileNode is created per
// *itemFile (shared between the friendly hierarchy and by-id lookups) so that a
@@ -450,7 +697,7 @@ func (d *dirNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
entries := make([]fuse.Dirent, 0, len(names))
for _, name := range names {
typ := fuse.DT_File
if _, ok := d.children[name].(*dirNode); ok {
if isDirectoryNode(d.children[name]) {
typ = fuse.DT_Dir
}
entries = append(entries, fuse.Dirent{Name: name, Type: typ})