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
This commit is contained in:
1 parent
c4a2e9d085
commit
5e88eaea97
6 files changed
+696
-98
No files matched your search
+138
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user