diff --git a/internal/sync/pull_hierarchy_issue_test.go b/internal/sync/pull_hierarchy_issue_test.go new file mode 100644 index 0000000..563041a --- /dev/null +++ b/internal/sync/pull_hierarchy_issue_test.go @@ -0,0 +1,37 @@ +package sync + +import ( + "testing" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" +) + +func TestPlanPagePaths_HierarchyWithSubpages(t *testing.T) { + spaceDir := t.TempDir() + + pages := []confluence.Page{ + {ID: "1", Title: "Root"}, + {ID: "2", Title: "Child", ParentPageID: "1"}, + {ID: "3", Title: "Grand Child", ParentPageID: "2"}, + {ID: "4", Title: "Leaf"}, + } + + _, relByID := PlanPagePaths(spaceDir, nil, pages, nil) + + // Root has a child (Child), so it should be Root/Root.md + if got := relByID["1"]; got != "Root/Root.md" { + t.Errorf("root path = %q, want Root/Root.md", got) + } + // Child has a child (Grand Child), so it should be Root/Child/Child.md + if got := relByID["2"]; got != "Root/Child/Child.md" { + t.Errorf("child path = %q, want Root/Child/Child.md", got) + } + // Grand Child has no children, so it should be Root/Child/Grand-Child.md + if got := relByID["3"]; got != "Root/Child/Grand-Child.md" { + t.Errorf("grandchild path = %q, want Root/Child/Grand-Child.md", got) + } + // Leaf has no children, so it should be Leaf.md + if got := relByID["4"]; got != "Leaf.md" { + t.Errorf("leaf path = %q, want Leaf.md", got) + } +} diff --git a/internal/sync/pull_paths.go b/internal/sync/pull_paths.go index d4455b2..ca8d6cb 100644 --- a/internal/sync/pull_paths.go +++ b/internal/sync/pull_paths.go @@ -23,12 +23,27 @@ func PlanPagePaths( folderByID map[string]confluence.Folder, ) (map[string]string, map[string]string) { pageByID := map[string]confluence.Page{} + hasChildren := map[string]bool{} for _, page := range pages { pageByID[page.ID] = page + if page.ParentType == "page" || page.ParentType == "" { + parentID := strings.TrimSpace(page.ParentPageID) + if parentID != "" { + hasChildren[parentID] = true + } + } } if folderByID == nil { folderByID = map[string]confluence.Folder{} } + for _, folder := range folderByID { + if folder.ParentType == "page" { + parentID := strings.TrimSpace(folder.ParentID) + if parentID != "" { + hasChildren[parentID] = true + } + } + } previousPathByID := map[string]string{} for _, previousPath := range sortedStringKeys(previousPageIndex) { pageID := previousPageIndex[previousPath] @@ -54,7 +69,7 @@ func PlanPagePaths( } plans := make([]pagePathPlan, 0, len(pages)) for _, page := range pages { - baseRelPath := plannedPageRelPath(page, pageByID, folderByID) + baseRelPath := plannedPageRelPath(page, pageByID, folderByID, hasChildren) if previousPath := previousPathByID[page.ID]; previousPath != "" && sameParentDirectory(previousPath, baseRelPath) { baseRelPath = previousPath } @@ -82,7 +97,7 @@ func PlanPagePaths( return absByID, relByID } -func plannedPageRelPath(page confluence.Page, pageByID map[string]confluence.Page, folderByID map[string]confluence.Folder) string { +func plannedPageRelPath(page confluence.Page, pageByID map[string]confluence.Page, folderByID map[string]confluence.Folder, hasChildren map[string]bool) string { title := strings.TrimSpace(page.Title) if title == "" { title = "page-" + page.ID @@ -96,6 +111,11 @@ func plannedPageRelPath(page confluence.Page, pageByID map[string]confluence.Pag } parts := append(ancestorSegments, filename) + if hasChildren[page.ID] { + // If the page has subpages, create a directory for it and place the page inside + dirSegment := fs.SanitizePathSegment(title) + parts = append(ancestorSegments, dirSegment, filename) + } return normalizeRelPath(filepath.Join(parts...)) } @@ -151,12 +171,8 @@ func ancestorPathSegments(parentID string, parentType string, pageByID map[strin } } - // Folders always contribute a directory segment (even top-level folders). - // Pages only contribute a segment when they themselves have a parent; the - // space-root page (no parent) does not create its own subdirectory. - if currentType == "folder" || nextID != "" { - segmentsReversed = append(segmentsReversed, fs.SanitizePathSegment(title)) - } + // All ancestors (folders and pages) contribute a directory segment to their descendants. + segmentsReversed = append(segmentsReversed, fs.SanitizePathSegment(title)) currentID = nextID currentType = nextType diff --git a/internal/sync/pull_paths_test.go b/internal/sync/pull_paths_test.go index 00d9bc5..b91ebd5 100644 --- a/internal/sync/pull_paths_test.go +++ b/internal/sync/pull_paths_test.go @@ -17,14 +17,14 @@ func TestPlanPagePaths_MaintainsConfluenceHierarchy(t *testing.T) { _, relByID := PlanPagePaths(spaceDir, nil, pages, nil) - if got := relByID["1"]; got != "Root.md" { - t.Fatalf("root path = %q, want Root.md", got) + if got := relByID["1"]; got != "Root/Root.md" { + t.Fatalf("root path = %q, want Root/Root.md", got) } - if got := relByID["2"]; got != "Child.md" { - t.Fatalf("child path = %q, want Child.md", got) + if got := relByID["2"]; got != "Root/Child/Child.md" { + t.Fatalf("child path = %q, want Root/Child/Child.md", got) } - if got := relByID["3"]; got != "Child/Grand-Child.md" { - t.Fatalf("grandchild path = %q, want Child/Grand-Child.md", got) + if got := relByID["3"]; got != "Root/Child/Grand-Child.md" { + t.Fatalf("grandchild path = %q, want Root/Child/Grand-Child.md", got) } } diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 4986d71..5b6db58 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -36,8 +36,8 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { } } - writeDoc("root.md", "1", "old root\n") - writeDoc("child.md", "2", "old child\n") + writeDoc("Root/Root.md", "1", "old root\n") + writeDoc("Root/Child.md", "2", "old child\n") writeDoc("deleted.md", "999", "to be deleted\n") legacyAssetPath := filepath.Join(spaceDir, "assets", "999", "att-old-legacy.png") @@ -51,9 +51,9 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { state := fs.SpaceState{ LastPullHighWatermark: "2026-02-01T09:00:00Z", PagePathIndex: map[string]string{ - "root.md": "1", - "child.md": "2", - "deleted.md": "999", + "Root/Root.md": "1", + "Root/Child.md": "2", + "deleted.md": "999", }, AttachmentIndex: map[string]string{ "assets/999/att-old-legacy.png": "att-old", @@ -124,17 +124,17 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { t.Fatalf("ListChanges since = %s, want %s", fake.lastChangeSince.Format(time.RFC3339), expectedSince.Format(time.RFC3339)) } - rootDoc, err := fs.ReadMarkdownDocument(filepath.Join(spaceDir, "root.md")) + rootDoc, err := fs.ReadMarkdownDocument(filepath.Join(spaceDir, "Root/Root.md")) if err != nil { t.Fatalf("read Root/Root.md: %v", err) } - if !strings.Contains(rootDoc.Body, "[Known](child.md#section-a)") { + if !strings.Contains(rootDoc.Body, "[Known](Child.md#section-a)") { t.Fatalf("expected rewritten known link in root body, got:\n%s", rootDoc.Body) } if !strings.Contains(rootDoc.Body, "[Missing](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=404)") { t.Fatalf("expected unresolved fallback link in root body, got:\n%s", rootDoc.Body) } - if !strings.Contains(rootDoc.Body, "![Diagram](assets/1/att-1-diagram.png)") { + if !strings.Contains(rootDoc.Body, "![Diagram](../assets/1/att-1-diagram.png)") { t.Fatalf("expected rewritten media link in root body, got:\n%s", rootDoc.Body) } if rootDoc.Frontmatter.Version != 5 { @@ -153,15 +153,12 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { if _, err := os.Stat(filepath.Join(spaceDir, "deleted.md")); !os.IsNotExist(err) { t.Fatalf("deleted.md should be deleted, stat error=%v", err) } - if _, err := os.Stat(filepath.Join(spaceDir, "root.md")); err != nil { + if _, err := os.Stat(filepath.Join(spaceDir, "Root/Root.md")); err != nil { t.Fatalf("root markdown should exist at space root, stat error=%v", err) } - if _, err := os.Stat(filepath.Join(spaceDir, "child.md")); err != nil { + if _, err := os.Stat(filepath.Join(spaceDir, "Root/Child.md")); err != nil { t.Fatalf("child markdown should exist at space root, stat error=%v", err) } - if _, err := os.Stat(filepath.Join(spaceDir, "Root")); !os.IsNotExist(err) { - t.Fatalf("nested Root directory should not exist, stat error=%v", err) - } if _, err := os.Stat(legacyAssetPath); !os.IsNotExist(err) { t.Fatalf("legacy asset should be deleted, stat error=%v", err) } @@ -186,11 +183,11 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { if result.State.SpaceKey != "ENG" { t.Fatalf("state space key = %q, want ENG", result.State.SpaceKey) } - if got := result.State.PagePathIndex["root.md"]; got != "1" { - t.Fatalf("state page_path_index[root.md] = %q, want 1", got) + if got := result.State.PagePathIndex["Root/Root.md"]; got != "1" { + t.Fatalf("state page_path_index[Root/Root.md] = %q, want 1", got) } - if got := result.State.PagePathIndex["child.md"]; got != "2" { - t.Fatalf("state page_path_index[child.md] = %q, want 2", got) + if got := result.State.PagePathIndex["Root/Child.md"]; got != "2" { + t.Fatalf("state page_path_index[Root/Child.md] = %q, want 2", got) } if _, exists := result.State.PagePathIndex["deleted.md"]; exists { t.Fatalf("state page_path_index should not include deleted.md")