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