Introduces a pure, FUSE-independent item tree (buildItemTree) shared by the mount and export commands. The new export subcommand downloads Docspell search results into a local directory, mirroring the mount's friendly folder/year/month hierarchy without the by-id directory or metadata JSON file, for archival or migration. - itemtree.go: pure item tree builder shared by mount and export - mount.go: buildMountTree now converts the pure tree into FUSE nodes, with a shared fileNode per attachment across friendly and by-id paths - export.go: export command with concurrent downloads, --dry-run, --skip-existing, --concurrency, --query, --limit, --auth-header - main.go: wires up the export subcommand and its flags - export_test.go: tests for plan collection, writes, skip-existing, failures, modtime - README.md: documents the export command
139 lines
4.1 KiB
Go
139 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// itemNode is either an itemDir or an itemFile in the pure, FUSE-independent
|
|
// representation of the Docspell item hierarchy built by buildItemTree.
|
|
type itemNode interface{ isItemNode() }
|
|
|
|
// itemDir is a directory in the pure item tree. It mirrors the FUSE dirNode but
|
|
// carries no FUSE-specific state, so it can be reused by the mount and export
|
|
// commands without importing bazil.org/fuse.
|
|
type itemDir struct {
|
|
name string
|
|
children map[string]itemNode
|
|
}
|
|
|
|
func (*itemDir) isItemNode() {}
|
|
|
|
// itemFile is a file entry backed by a Docspell attachment. The same *itemFile
|
|
// pointer is referenced from both the friendly hierarchy and the by-id tree, so
|
|
// consumers (mount) can share a single backing node between both locations.
|
|
type itemFile struct {
|
|
name string
|
|
attachmentID string
|
|
size int64
|
|
modTime time.Time
|
|
}
|
|
|
|
func (*itemFile) isItemNode() {}
|
|
|
|
// buildItemTree builds the pure item tree for the given Docspell search items.
|
|
//
|
|
// The returned root contains the friendly hierarchy (folder/year/month/<name>)
|
|
// plus a "by-id" directory with stable ID-based lookups. Files in the friendly
|
|
// hierarchy and the by-id tree share the same *itemFile pointers. The returned
|
|
// metadata maps item IDs to their friendly and by-id relative paths.
|
|
//
|
|
// The metadata JSON file itself is intentionally not part of the tree; callers
|
|
// that need it (the mount command) add it from the returned metadata.
|
|
func buildItemTree(items []SearchItem) (root *itemDir, metadata mountMetadata) {
|
|
root = &itemDir{name: "", children: map[string]itemNode{}}
|
|
byID := &itemDir{name: "by-id", children: map[string]itemNode{}}
|
|
root.children["by-id"] = byID
|
|
metadata = mountMetadata{Items: map[string]mountedItemMetadata{}}
|
|
|
|
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")
|
|
}
|
|
dirParts := append([]string{folderName}, strings.Split(yearMonth, "/")...)
|
|
dir := ensureItemDir(root, dirParts[0], dirParts[1:]...)
|
|
|
|
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)
|
|
}
|
|
name = uniqueItemName(dir.children, sanitizeName(name))
|
|
file := &itemFile{name: name, attachmentID: att.ID, size: att.Size, modTime: modTime}
|
|
dir.children[name] = file
|
|
friendlyRelPath := filepath.ToSlash(filepath.Join(append(sanitizePathParts(dirParts), name)...))
|
|
|
|
byIDName := item.ID + ext
|
|
if len(item.Attachments) > 1 && i > 0 {
|
|
byIDName = fmt.Sprintf("%s-%d%s", item.ID, i+1, ext)
|
|
}
|
|
shard := idShard(item.ID)
|
|
shardDir := ensureItemDir(byID, shard)
|
|
byIDName = uniqueItemName(shardDir.children, sanitizeName(byIDName))
|
|
shardDir.children[byIDName] = file
|
|
|
|
if i == 0 {
|
|
metadata.Items[item.ID] = mountedItemMetadata{
|
|
FriendlyPath: friendlyRelPath,
|
|
ByIDPath: filepath.ToSlash(filepath.Join("by-id", shard, byIDName)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return root, metadata
|
|
}
|
|
|
|
func ensureItemDir(root *itemDir, first string, rest ...string) *itemDir {
|
|
cur := root
|
|
parts := append([]string{first}, rest...)
|
|
for _, part := range parts {
|
|
part = sanitizeName(part)
|
|
if part == "" {
|
|
part = "_"
|
|
}
|
|
next, ok := cur.children[part].(*itemDir)
|
|
if !ok {
|
|
next = &itemDir{name: part, children: map[string]itemNode{}}
|
|
cur.children[part] = next
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func uniqueItemName(children map[string]itemNode, 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
|
|
}
|
|
}
|
|
}
|