Files
beads/cmd/bd/export_obsidian.go
furiosa bf378650f4 feat(types): add rig identity bead type (gt-zmznh)
- Add TypeRig constant to IssueType enum
- Update IsValid() method to include TypeRig
- Add UI color (orange) and style for rig type
- Update CLI flag descriptions in create and update commands
- Add Obsidian export tag for rig type
- Add comprehensive test cases for rig and other newer types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:55:45 -08:00

208 lines
5.6 KiB
Go

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.TypeRig: "#Rig",
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
}