Fetch the uniquely identified item when mount metadata has not yet been
populated, so get-path does not fall back to the ID-based location.
💘 Generated with Crush
Assisted-by: Crush:openai/gpt-5.6-terra
838 lines
22 KiB
Go
838 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
|
|
const docspellIDCharacters = "abcdefghijklmnopqrstuvwxyz0123456789-_.@"
|
|
|
|
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
|
|
}
|
|
root := buildLazyMountTree(client, options.query, options.limit)
|
|
|
|
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 read-only at %s. Unmount with: fusermount -u %s\n", 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
|
|
}
|
|
client := NewDocspellClient(cfg.DocspellURL, defaultDocspellAuthHeader)
|
|
if err := client.Login(context.Background(), cfg.User, password); err != nil {
|
|
return fmt.Errorf("login: %w", err)
|
|
}
|
|
items, err := client.SearchItems(context.Background(), "id:"+id, 1)
|
|
if err != nil {
|
|
return fmt.Errorf("search item %s: %w", id, err)
|
|
}
|
|
if path := friendlyLocalPathForSearchItems(mountPoint, id, items); 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 friendlyLocalPathForSearchItems(mountPoint, id string, items []SearchItem) string {
|
|
_, metadata := buildItemTree(items)
|
|
for itemID, item := range metadata.Items {
|
|
if strings.EqualFold(itemID, id) && item.FriendlyPath != "" {
|
|
return filepath.Join(mountPoint, filepath.FromSlash(item.FriendlyPath))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 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 {
|
|
items *lazyItemDirNode
|
|
}
|
|
|
|
type lazyItemDirNode struct {
|
|
query string
|
|
client *DocspellClient
|
|
limit int
|
|
mu sync.Mutex
|
|
cache map[string]fs.Node
|
|
populated bool
|
|
}
|
|
|
|
type lazyByIDNode struct {
|
|
client *DocspellClient
|
|
query string
|
|
limit int
|
|
mu sync.Mutex
|
|
shards map[string]*lazyByIDShardNode
|
|
}
|
|
|
|
type lazyByIDShardNode struct {
|
|
query string
|
|
client *DocspellClient
|
|
limit int
|
|
mu sync.Mutex
|
|
cache map[string]fs.Node
|
|
populated bool
|
|
}
|
|
|
|
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{items: &lazyItemDirNode{
|
|
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 d.items.Lookup(ctx, name)
|
|
}
|
|
|
|
func (d *lazyMonthNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
|
|
return d.items.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, refresh bool) (map[string]fs.Node, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.populated && !refresh {
|
|
return d.cache, nil
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
d.cache = children
|
|
d.populated = true
|
|
return d.cache, 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, false)
|
|
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, true)
|
|
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 !isDocspellIDPrefix(name) {
|
|
return nil, fuse.ENOENT
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.shards == nil {
|
|
d.shards = map[string]*lazyByIDShardNode{}
|
|
}
|
|
shard, ok := d.shards[name]
|
|
if !ok {
|
|
shard = &lazyByIDShardNode{
|
|
query: combineItemQueries(d.query, "id:"+name+"*"),
|
|
client: d.client,
|
|
limit: d.limit,
|
|
}
|
|
d.shards[name] = shard
|
|
}
|
|
return shard, 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 isDocspellIDPrefix(prefix string) bool {
|
|
if len(prefix) != 2 {
|
|
return false
|
|
}
|
|
for _, char := range prefix {
|
|
if !strings.ContainsRune(docspellIDCharacters, char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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, refresh bool) (map[string]fs.Node, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.populated && !refresh {
|
|
return d.cache, nil
|
|
}
|
|
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
|
|
}
|
|
children := map[string]fs.Node{}
|
|
for _, child := range byID.children {
|
|
if shard, ok := child.(*dirNode); ok {
|
|
children = shard.children
|
|
break
|
|
}
|
|
}
|
|
d.cache = children
|
|
d.populated = true
|
|
return d.cache, nil
|
|
}
|
|
|
|
func (d *lazyByIDShardNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
|
|
children, err := d.children(ctx, false)
|
|
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, true)
|
|
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
|
|
// downloaded attachment is cached once regardless of which path reads it.
|
|
func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchItem) *dirNode {
|
|
root, metadata := buildItemTree(items)
|
|
fuseRoot := convertItemDir(root, ctx, client, map[*itemFile]*fileNode{})
|
|
|
|
metadataData, err := json.MarshalIndent(metadata, "", " ")
|
|
if err == nil {
|
|
fuseRoot.children[mountMetadataFileName] = &staticFileNode{
|
|
name: mountMetadataFileName,
|
|
data: append(metadataData, '\n'),
|
|
modTime: time.Now(),
|
|
}
|
|
}
|
|
return fuseRoot
|
|
}
|
|
|
|
// convertItemDir recursively converts a pure itemDir into a FUSE dirNode. The
|
|
// fileCache ensures that the same *itemFile (referenced from both the friendly
|
|
// hierarchy and the by-id tree) maps to a single shared *fileNode.
|
|
func convertItemDir(dir *itemDir, ctx context.Context, client *DocspellClient, fileCache map[*itemFile]*fileNode) *dirNode {
|
|
node := &dirNode{name: dir.name, children: map[string]fs.Node{}}
|
|
for name, child := range dir.children {
|
|
switch c := child.(type) {
|
|
case *itemDir:
|
|
node.children[name] = convertItemDir(c, ctx, client, fileCache)
|
|
case *itemFile:
|
|
node.children[name] = convertItemFile(c, ctx, client, fileCache)
|
|
}
|
|
}
|
|
return node
|
|
}
|
|
|
|
func convertItemFile(file *itemFile, ctx context.Context, client *DocspellClient, fileCache map[*itemFile]*fileNode) *fileNode {
|
|
if cached, ok := fileCache[file]; ok {
|
|
return cached
|
|
}
|
|
size := file.size
|
|
knownSize := size > 0
|
|
if size <= 0 {
|
|
if headSize, err := client.AttachmentSize(ctx, file.attachmentID); err == nil && headSize > 0 {
|
|
size = headSize
|
|
knownSize = true
|
|
}
|
|
}
|
|
node := &fileNode{
|
|
name: file.name,
|
|
attachmentID: file.attachmentID,
|
|
size: uint64(size),
|
|
knownSize: knownSize,
|
|
modTime: file.modTime,
|
|
client: client,
|
|
}
|
|
fileCache[file] = node
|
|
return node
|
|
}
|
|
|
|
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 = strings.ToLower(sanitizeName(id))
|
|
if len(id) >= 2 {
|
|
return id[:2]
|
|
}
|
|
if id != "" {
|
|
return id
|
|
}
|
|
return "__"
|
|
}
|
|
|
|
func sanitizeName(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, "/", "_")
|
|
s = strings.ReplaceAll(s, "\x00", "_")
|
|
return s
|
|
}
|
|
|
|
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 isDirectoryNode(d.children[name]) {
|
|
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
|
|
}
|