Add background mount path helpers
This commit is contained in:
@@ -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 <item-id-or-web-url>
|
||||
docspell-import get-url <local-mounted-path-or-item-id>
|
||||
```
|
||||
|
||||
`get-local-path` starts the configured mount in the background if it is not mounted yet.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
|
||||
@@ -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] <mountpoint>", 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 <item-id-or-url>", 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 <local-path-or-item-id>", 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{}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user