diff --git a/README.md b/README.md index e043784..7bd0be4 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,13 @@ Erstellen Sie eine `docspell-import.json` Datei in Ihrem Home-Verzeichnis: [Beis The `mount` subcommand exposes a Docspell collection as a read-only filesystem. It uses the same `~/docspell-import.json` configuration and password command as the import command. +Set `mountPath` in the config to define the default mount point. ```bash mkdir -p ~/mnt/docspell -docspell-import mount --query '*' ~/mnt/docspell +docspell-import mount --query '*' ~/mnt/docspell # starts in the background +# or, with mountPath configured: +docspell-import mount --query '*' # unmount when done fusermount -u ~/mnt/docspell ``` @@ -47,3 +50,12 @@ Options: - `--auth-header`: token header, default `X-Docspell-Auth`. The mount is read-only. File contents are downloaded lazily from `/api/v1/sec/attachment/{id}` when read. + +Helper commands: + +```bash +docspell-import get-local-path +docspell-import get-url +``` + +`get-local-path` starts the configured mount in the background if it is not mounted yet. diff --git a/docspell-import-example.json b/docspell-import-example.json index 3902fd0..5ba73d6 100644 --- a/docspell-import-example.json +++ b/docspell-import-example.json @@ -3,6 +3,7 @@ "passwordCommandArgs": ["show", "docspell/password"], "user": "admin@example.com", "docspellURL": "https://docspell.example.com", + "mountPath": "/home/user/mnt/docspell", "archiveDirectory": "/home/user/Documents/Archive", "importDirectories": [ "/home/user/Documents/Inbox", diff --git a/main.go b/main.go index ef0d182..778b56f 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ type config struct { PasswordCommandArgs []string User string DocspellURL string + MountPath string ArchiveDirectory string ImportDirectories []string } @@ -36,13 +37,6 @@ type DocspellItem struct { } func main() { - fmt.Println("##################### START #####################") - fmt.Println(" Docspell Import - v0.3") - fmt.Println(" by jacob1123") - fmt.Println(" (based on work by totti4ever) ") - fmt.Println("#################################################") - fmt.Println() - cfg, password := loadConfigAndPassword() if len(os.Args) > 1 && os.Args[1] == "mount" { @@ -52,6 +46,27 @@ func main() { } return } + if len(os.Args) > 1 && os.Args[1] == "get-local-path" { + if err := runGetLocalPathCommand(os.Args[2:], cfg, password); err != nil { + fmt.Fprintln(os.Stderr, "get-local-path failed:", err) + os.Exit(1) + } + return + } + if len(os.Args) > 1 && os.Args[1] == "get-url" { + if err := runGetURLCommand(os.Args[2:], cfg, password); err != nil { + fmt.Fprintln(os.Stderr, "get-url failed:", err) + os.Exit(1) + } + return + } + + fmt.Println("##################### START #####################") + fmt.Println(" Docspell Import - v0.3") + fmt.Println(" by jacob1123") + fmt.Println(" (based on work by totti4ever) ") + fmt.Println("#################################################") + fmt.Println() runImportCommand(cfg, password, os.Args[1:]) } diff --git a/mount.go b/mount.go index 93ebdb0..2b978a7 100644 --- a/mount.go +++ b/mount.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -42,8 +43,21 @@ func runMountCommand(args []string, cfg config, password string) error { if err := fsFlags.Parse(args); err != nil { return err } - if fsFlags.NArg() != 1 { - return fmt.Errorf("usage: %s mount [flags] ", filepath.Base(os.Args[0])) + mountPoint := cfg.MountPath + if fsFlags.NArg() > 1 { + return fmt.Errorf("usage: %s mount [flags] [mountpoint]", filepath.Base(os.Args[0])) + } + if fsFlags.NArg() == 1 { + mountPoint = fsFlags.Arg(0) + } + if mountPoint == "" { + return fmt.Errorf("mount point missing: pass one or set MountPath in config") + } + if os.Getenv("DOCSPELL_IMPORT_MOUNT_FOREGROUND") != "1" { + return startMountInBackground(args, mountPoint, false) + } + if err := os.MkdirAll(mountPoint, 0755); err != nil { + return err } client := NewDocspellClient(cfg.DocspellURL, *authHeader) @@ -57,7 +71,7 @@ func runMountCommand(args []string, cfg config, password string) error { root := buildMountTree(context.Background(), client, items) conn, err := fuse.Mount( - fsFlags.Arg(0), + mountPoint, fuse.FSName("docspell"), fuse.Subtype("docspell-import"), fuse.ReadOnly(), @@ -67,10 +81,161 @@ 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), fsFlags.Arg(0), fsFlags.Arg(0)) + fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), mountPoint, mountPoint) return fs.Serve(conn, &docspellFS{root: root}) } +func startMountInBackground(args []string, mountPoint string, quiet bool) error { + 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_IMPORT_MOUNT_FOREGROUND=1") + cmd.Stdout = nil + cmd.Stderr = nil + 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 runGetLocalPathCommand(args []string, cfg config, password string) error { + flags := flag.NewFlagSet("get-local-path", flag.ExitOnError) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: %s get-local-path ", filepath.Base(os.Args[0])) + } + mountPoint, err := ensureMounted(cfg, password) + if err != nil { + return err + } + id := itemIDFromURLOrID(flags.Arg(0)) + 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 runGetURLCommand(args []string, cfg config, password string) error { + flags := flag.NewFlagSet("get-url", flag.ExitOnError) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: %s get-url ", filepath.Base(os.Args[0])) + } + id := flags.Arg(0) + if strings.Contains(id, string(os.PathSeparator)) || strings.HasPrefix(id, ".") { + var err error + 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 ensureMounted(cfg config, password string) (string, error) { + mountPoint := cfg.MountPath + if mountPoint == "" { + return "", fmt.Errorf("MountPath missing in config") + } + if isMounted(mountPoint) { + return mountPoint, nil + } + if err := startMountInBackground([]string{mountPoint}, mountPoint, true); err != nil { + return "", err + } + for i := 0; i < 50; i++ { + if isMounted(mountPoint) { + return mountPoint, nil + } + time.Sleep(100 * time.Millisecond) + } + return "", fmt.Errorf("mount did not become ready at %s", mountPoint) +} + +func isMounted(mountPoint string) bool { + data, err := os.ReadFile("/proc/mounts") + if err != nil { + return false + } + mountPoint, _ = filepath.Abs(mountPoint) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == mountPoint { + return true + } + } + return false +} + +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 + } + info, err := os.Stat(abs) + 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 + } + 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 +} + 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{}} diff --git a/mount_test.go b/mount_test.go index e927c4f..5bc0de4 100644 --- a/mount_test.go +++ b/mount_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "path/filepath" "testing" "bazil.org/fuse" @@ -36,6 +37,27 @@ func TestBuildMountTreeGroupsByFolderAndDate(t *testing.T) { } } +func TestItemIDFromURLOrID(t *testing.T) { + if got := itemIDFromURLOrID("https://docspell.example/app/item/item-123"); got != "item-123" { + t.Fatalf("expected item-123, got %q", got) + } + if got := itemIDFromURLOrID("item-456"); got != "item-456" { + t.Fatalf("expected raw id, got %q", got) + } +} + +func TestItemIDFromLocalByIDPath(t *testing.T) { + mountPath := t.TempDir() + path := filepath.Join(mountPath, "by-id", "it", "item-1.pdf") + got, err := itemIDFromLocalPath(path, 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",