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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user