feat: add Obsidian Tasks markdown export format (GH#819)
Merge PR #819 from justbry with improvements: - Add --format obsidian option to bd export - Generate Obsidian Tasks-compatible markdown - Default output to ai_docs/changes-log.md - Map status to checkboxes, priority to emoji, type to tags - Support parent-child hierarchy with indentation - Use official Obsidian Tasks format (🆔, ⛔ emojis) Improvement over PR: replaced O(n²) bubble sort with slices.SortFunc for date ordering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: justbry <justbu42@proton.me> Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
8ab9b815ba
commit
ee51298fd5
206
cmd/bd/export_obsidian.go
Normal file
206
cmd/bd/export_obsidian.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// obsidianCheckbox maps bd status to Obsidian Tasks checkbox syntax
|
||||
var obsidianCheckbox = map[types.Status]string{
|
||||
types.StatusOpen: "- [ ]",
|
||||
types.StatusInProgress: "- [/]",
|
||||
types.StatusBlocked: "- [c]",
|
||||
types.StatusClosed: "- [x]",
|
||||
types.StatusTombstone: "- [-]",
|
||||
types.StatusDeferred: "- [-]",
|
||||
types.StatusPinned: "- [n]", // Review/attention
|
||||
types.StatusHooked: "- [/]", // Treat as in-progress
|
||||
}
|
||||
|
||||
// obsidianPriority maps bd priority (0-4) to Obsidian priority emoji
|
||||
var obsidianPriority = []string{
|
||||
"🔺", // 0 = critical/highest
|
||||
"⏫", // 1 = high
|
||||
"🔼", // 2 = medium
|
||||
"🔽", // 3 = low
|
||||
"⏬", // 4 = backlog/lowest
|
||||
}
|
||||
|
||||
// obsidianTypeTag maps bd issue type to Obsidian tag
|
||||
var obsidianTypeTag = map[types.IssueType]string{
|
||||
types.TypeBug: "#Bug",
|
||||
types.TypeFeature: "#Feature",
|
||||
types.TypeTask: "#Task",
|
||||
types.TypeEpic: "#Epic",
|
||||
types.TypeChore: "#Chore",
|
||||
types.TypeMessage: "#Message",
|
||||
types.TypeMergeRequest: "#MergeRequest",
|
||||
types.TypeMolecule: "#Molecule",
|
||||
types.TypeGate: "#Gate",
|
||||
types.TypeAgent: "#Agent",
|
||||
types.TypeRole: "#Role",
|
||||
types.TypeConvoy: "#Convoy",
|
||||
types.TypeEvent: "#Event",
|
||||
}
|
||||
|
||||
// formatObsidianTask converts a single issue to Obsidian Tasks format
|
||||
func formatObsidianTask(issue *types.Issue) string {
|
||||
var parts []string
|
||||
|
||||
// Checkbox based on status
|
||||
checkbox, ok := obsidianCheckbox[issue.Status]
|
||||
if !ok {
|
||||
checkbox = "- [ ]" // default to open
|
||||
}
|
||||
parts = append(parts, checkbox)
|
||||
|
||||
// Title first
|
||||
parts = append(parts, issue.Title)
|
||||
|
||||
// Task ID with 🆔 emoji (official Obsidian Tasks format)
|
||||
parts = append(parts, fmt.Sprintf("🆔 %s", issue.ID))
|
||||
|
||||
// Priority emoji
|
||||
if issue.Priority >= 0 && issue.Priority < len(obsidianPriority) {
|
||||
parts = append(parts, obsidianPriority[issue.Priority])
|
||||
}
|
||||
|
||||
// Type tag
|
||||
if tag, ok := obsidianTypeTag[issue.IssueType]; ok {
|
||||
parts = append(parts, tag)
|
||||
}
|
||||
|
||||
// Labels as tags
|
||||
for _, label := range issue.Labels {
|
||||
// Sanitize label for tag use (replace spaces with dashes)
|
||||
tag := "#" + strings.ReplaceAll(label, " ", "-")
|
||||
parts = append(parts, tag)
|
||||
}
|
||||
|
||||
// Start date (created_at)
|
||||
parts = append(parts, fmt.Sprintf("🛫 %s", issue.CreatedAt.Format("2006-01-02")))
|
||||
|
||||
// End date (closed_at) if closed
|
||||
if issue.ClosedAt != nil {
|
||||
parts = append(parts, fmt.Sprintf("✅ %s", issue.ClosedAt.Format("2006-01-02")))
|
||||
}
|
||||
|
||||
// Dependencies with ⛔ emoji (official Obsidian Tasks "blocked by" format)
|
||||
// Include both blocks and parent-child relationships
|
||||
for _, dep := range issue.Dependencies {
|
||||
if dep.Type == types.DepBlocks || dep.Type == types.DepParentChild {
|
||||
parts = append(parts, fmt.Sprintf("⛔ %s", dep.DependsOnID))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// groupIssuesByDate groups issues by their most recent activity date
|
||||
func groupIssuesByDate(issues []*types.Issue) map[string][]*types.Issue {
|
||||
grouped := make(map[string][]*types.Issue)
|
||||
for _, issue := range issues {
|
||||
// Use the most recent date: closed_at > updated_at > created_at
|
||||
var date time.Time
|
||||
if issue.ClosedAt != nil {
|
||||
date = *issue.ClosedAt
|
||||
} else {
|
||||
date = issue.UpdatedAt
|
||||
}
|
||||
key := date.Format("2006-01-02")
|
||||
grouped[key] = append(grouped[key], issue)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// buildParentChildMap builds a map of parent ID -> child issues from parent-child dependencies
|
||||
func buildParentChildMap(issues []*types.Issue) (map[string][]*types.Issue, map[string]bool) {
|
||||
parentToChildren := make(map[string][]*types.Issue)
|
||||
isChild := make(map[string]bool)
|
||||
|
||||
// Build lookup map
|
||||
issueByID := make(map[string]*types.Issue)
|
||||
for _, issue := range issues {
|
||||
issueByID[issue.ID] = issue
|
||||
}
|
||||
|
||||
// Find parent-child relationships
|
||||
for _, issue := range issues {
|
||||
for _, dep := range issue.Dependencies {
|
||||
if dep.Type == types.DepParentChild {
|
||||
parentID := dep.DependsOnID
|
||||
parentToChildren[parentID] = append(parentToChildren[parentID], issue)
|
||||
isChild[issue.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parentToChildren, isChild
|
||||
}
|
||||
|
||||
// writeObsidianExport writes issues in Obsidian Tasks markdown format
|
||||
func writeObsidianExport(w io.Writer, issues []*types.Issue) error {
|
||||
// Write header
|
||||
if _, err := fmt.Fprintln(w, "# Changes Log"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build parent-child hierarchy
|
||||
parentToChildren, isChild := buildParentChildMap(issues)
|
||||
|
||||
// Group by date
|
||||
grouped := groupIssuesByDate(issues)
|
||||
|
||||
// Get sorted dates (most recent first)
|
||||
dates := make([]string, 0, len(grouped))
|
||||
for date := range grouped {
|
||||
dates = append(dates, date)
|
||||
}
|
||||
// Sort descending (reverse order)
|
||||
slices.SortFunc(dates, func(a, b string) int {
|
||||
return cmp.Compare(b, a) // reverse: b before a for descending
|
||||
})
|
||||
|
||||
// Write each date section
|
||||
for _, date := range dates {
|
||||
if _, err := fmt.Fprintf(w, "## %s\n\n", date); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, issue := range grouped[date] {
|
||||
// Skip children - they'll be written under their parent
|
||||
if isChild[issue.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Write parent issue
|
||||
line := formatObsidianTask(issue)
|
||||
if _, err := fmt.Fprintln(w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write children indented
|
||||
if children, ok := parentToChildren[issue.ID]; ok {
|
||||
for _, child := range children {
|
||||
childLine := " " + formatObsidianTask(child)
|
||||
if _, err := fmt.Fprintln(w, childLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user