471 lines
12 KiB
Go
471 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"bazil.org/fuse"
|
|
"bazil.org/fuse/fs"
|
|
"bazil.org/fuse/fuseutil"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func runMountCommand(args []string, cfg config, password string) error {
|
|
fsFlags := flag.NewFlagSet("mount", flag.ExitOnError)
|
|
query := fsFlags.String("query", cfg.MountQuery, "Docspell item query to expose")
|
|
limit := fsFlags.Int("limit", 1000, "number of items to fetch per API request")
|
|
authHeader := fsFlags.String("auth-header", defaultDocspellAuthHeader, "HTTP header used for the Docspell auth token")
|
|
if err := fsFlags.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
if err := client.Login(context.Background(), cfg.User, password); err != nil {
|
|
return err
|
|
}
|
|
items, err := client.SearchItems(context.Background(), *query, *limit)
|
|
if err != nil {
|
|
return fmt.Errorf("search items: %w", err)
|
|
}
|
|
root := buildMountTree(context.Background(), client, items)
|
|
|
|
conn, err := fuse.Mount(
|
|
mountPoint,
|
|
fuse.FSName("docspell"),
|
|
fuse.Subtype("docspell-import"),
|
|
fuse.ReadOnly(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
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 {
|
|
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_IMPORT_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 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 := 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-import-mount.log")
|
|
}
|
|
return filepath.Join(cacheDir, "docspell-import", "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
|
|
}
|
|
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{}}
|
|
root.children["by-id"] = byID
|
|
for _, item := range items {
|
|
folderName := "No Folder"
|
|
if item.Folder != nil && item.Folder.Name != "" {
|
|
folderName = item.Folder.Name
|
|
}
|
|
date := item.Date
|
|
if date == 0 {
|
|
date = item.ItemDate
|
|
}
|
|
yearMonth := "No Date"
|
|
modTime := time.Now()
|
|
if date > 0 {
|
|
modTime = time.UnixMilli(date)
|
|
yearMonth = modTime.Format("2006/01")
|
|
}
|
|
dir := ensureDir(root, folderName, strings.Split(yearMonth, "/")...)
|
|
base := sanitizeName(item.Name)
|
|
if base == "" {
|
|
base = item.ID
|
|
}
|
|
for i, att := range item.Attachments {
|
|
ext := filepath.Ext(att.Name)
|
|
if ext == "" {
|
|
ext = ".pdf"
|
|
}
|
|
name := base + ext
|
|
if len(item.Attachments) > 1 {
|
|
name = fmt.Sprintf("%s - %02d%s", base, i+1, ext)
|
|
}
|
|
size := att.Size
|
|
if size <= 0 {
|
|
if headSize, err := client.AttachmentSize(ctx, att.ID); err == nil && headSize > 0 {
|
|
size = headSize
|
|
}
|
|
}
|
|
name = uniqueName(dir.children, sanitizeName(name))
|
|
node := &fileNode{name: name, attachmentID: att.ID, size: uint64(size), knownSize: size > 0, modTime: modTime, client: client}
|
|
dir.children[name] = node
|
|
|
|
byIDName := item.ID + ext
|
|
if len(item.Attachments) > 1 {
|
|
if i > 0 {
|
|
byIDName = fmt.Sprintf("%s-%d%s", item.ID, i+1, ext)
|
|
}
|
|
}
|
|
shard := idShard(item.ID)
|
|
shardDir := ensureDir(byID, shard)
|
|
shardDir.children[uniqueName(shardDir.children, sanitizeName(byIDName))] = node
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func idShard(id string) string {
|
|
id = sanitizeName(id)
|
|
if len(id) >= 2 {
|
|
return id[:2]
|
|
}
|
|
if id != "" {
|
|
return id
|
|
}
|
|
return "__"
|
|
}
|
|
|
|
func ensureDir(root *dirNode, first string, rest ...string) *dirNode {
|
|
cur := root
|
|
parts := append([]string{first}, rest...)
|
|
for _, part := range parts {
|
|
part = sanitizeName(part)
|
|
if part == "" {
|
|
part = "_"
|
|
}
|
|
next, ok := cur.children[part].(*dirNode)
|
|
if !ok {
|
|
next = &dirNode{name: part, children: map[string]fs.Node{}}
|
|
cur.children[part] = next
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func sanitizeName(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, "/", "_")
|
|
s = strings.ReplaceAll(s, "\x00", "_")
|
|
return s
|
|
}
|
|
|
|
func uniqueName(children map[string]fs.Node, name string) string {
|
|
if _, ok := children[name]; !ok {
|
|
return name
|
|
}
|
|
ext := filepath.Ext(name)
|
|
base := strings.TrimSuffix(name, ext)
|
|
for i := 2; ; i++ {
|
|
candidate := fmt.Sprintf("%s (%d)%s", base, i, ext)
|
|
if _, ok := children[candidate]; !ok {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
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 _, ok := d.children[name].(*dirNode); ok {
|
|
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 *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
|
|
}
|