Files
docspell-cli/itemtree.go
T
Jan Bader 5e88eaea97 Add export command and shared item tree
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
2026-07-21 23:35:03 +02:00

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
}
}
}