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:
Jan Bader committed 2026-07-21 23:35:03 +02:00
1 parent c4a2e9d085
commit 5e88eaea97
6 files changed
+696 -98

No files matched your search

+52 -97
View File
@@ -337,76 +337,63 @@ func itemIDFromLocalPath(path string, cfg config) (string, error) {
return "", fmt.Errorf("could not map mounted path to item id")
}
// buildMountTree converts the pure item tree produced by buildItemTree into the
// FUSE node tree served by the mount command. A single *fileNode is created per
// *itemFile (shared between the friendly hierarchy and by-id lookups) so that a
// downloaded attachment is cached once regardless of which path reads it.
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
metadata := mountMetadata{Items: map[string]mountedItemMetadata{}}
metadataModTime := time.Now()
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 := ensureDir(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)
}
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
friendlyRelPath := filepath.ToSlash(filepath.Join(append(sanitizePathParts(dirParts), name)...))
root, metadata := buildItemTree(items)
fuseRoot := convertItemDir(root, ctx, client, map[*itemFile]*fileNode{})
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)
byIDName = uniqueName(shardDir.children, sanitizeName(byIDName))
shardDir.children[byIDName] = node
if i == 0 {
metadata.Items[item.ID] = mountedItemMetadata{
FriendlyPath: friendlyRelPath,
ByIDPath: filepath.ToSlash(filepath.Join("by-id", shard, byIDName)),
}
}
}
}
metadataData, err := json.MarshalIndent(metadata, "", " ")
if err == nil {
root.children[mountMetadataFileName] = &staticFileNode{name: mountMetadataFileName, data: append(metadataData, '\n'), modTime: metadataModTime}
fuseRoot.children[mountMetadataFileName] = &staticFileNode{
name: mountMetadataFileName,
data: append(metadataData, '\n'),
modTime: time.Now(),
}
}
return root
return fuseRoot
}
// convertItemDir recursively converts a pure itemDir into a FUSE dirNode. The
// fileCache ensures that the same *itemFile (referenced from both the friendly
// hierarchy and the by-id tree) maps to a single shared *fileNode.
func convertItemDir(dir *itemDir, ctx context.Context, client *DocspellClient, fileCache map[*itemFile]*fileNode) *dirNode {
node := &dirNode{name: dir.name, children: map[string]fs.Node{}}
for name, child := range dir.children {
switch c := child.(type) {
case *itemDir:
node.children[name] = convertItemDir(c, ctx, client, fileCache)
case *itemFile:
node.children[name] = convertItemFile(c, ctx, client, fileCache)
}
}
return node
}
func convertItemFile(file *itemFile, ctx context.Context, client *DocspellClient, fileCache map[*itemFile]*fileNode) *fileNode {
if cached, ok := fileCache[file]; ok {
return cached
}
size := file.size
knownSize := size > 0
if size <= 0 {
if headSize, err := client.AttachmentSize(ctx, file.attachmentID); err == nil && headSize > 0 {
size = headSize
knownSize = true
}
}
node := &fileNode{
name: file.name,
attachmentID: file.attachmentID,
size: uint64(size),
knownSize: knownSize,
modTime: file.modTime,
client: client,
}
fileCache[file] = node
return node
}
func sanitizePathParts(parts []string) []string {
@@ -432,24 +419,6 @@ func idShard(id string) string {
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, "/", "_")
@@ -457,20 +426,6 @@ func sanitizeName(s string) string {
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 {