tiki/store/tikistore/template.go
2026-04-16 15:35:28 -04:00

242 lines
6.2 KiB
Go

package tikistore
import (
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/boolean-maybe/tiki/config"
taskpkg "github.com/boolean-maybe/tiki/task"
"gopkg.in/yaml.v3"
)
const templateSource = "<template>"
// templateFrontmatter represents the YAML frontmatter in template files
type templateFrontmatter struct {
Title string `yaml:"title"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
DependsOn []string `yaml:"dependsOn"`
Due string `yaml:"due"`
Recurrence string `yaml:"recurrence"`
Assignee string `yaml:"assignee"`
Priority int `yaml:"priority"`
Points int `yaml:"points"`
}
// loadTemplateTask reads new.md from the highest-priority location
// (cwd > .doc/ > user config), or falls back to the embedded template.
func loadTemplateTask() (*taskpkg.Task, error) {
templatePath := config.FindTemplateFile()
if templatePath == "" {
slog.Debug("no new.md found in any search path, using embedded template")
return loadEmbeddedTemplate()
}
data, err := os.ReadFile(templatePath)
if err != nil {
slog.Warn("failed to read new.md template", "path", templatePath, "error", err)
return loadEmbeddedTemplate()
}
slog.Debug("loaded new.md template", "path", templatePath)
return parseTaskTemplate(data)
}
// loadEmbeddedTemplate loads the embedded config/new.md template
func loadEmbeddedTemplate() (*taskpkg.Task, error) {
templateStr := config.GetDefaultNewTaskTemplate()
if templateStr == "" {
return nil, nil
}
return parseTaskTemplate([]byte(templateStr))
}
// parseTaskTemplate parses task template data from markdown with YAML frontmatter
func parseTaskTemplate(data []byte) (*taskpkg.Task, error) {
content := strings.TrimSpace(string(data))
if !strings.HasPrefix(content, "---") {
return nil, nil
}
rest := content[3:]
idx := strings.Index(rest, "\n---")
if idx == -1 {
return nil, nil
}
frontmatter := strings.TrimSpace(rest[:idx])
body := strings.TrimSpace(strings.TrimPrefix(rest[idx+4:], "\n"))
var fm templateFrontmatter
if err := yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
return nil, nil
}
// Parse due date if provided
var dueTime time.Time
if fm.Due != "" {
parsed, ok := taskpkg.ParseDueDate(fm.Due)
if ok {
dueTime = parsed
}
}
// Parse recurrence if provided
var recurrence taskpkg.Recurrence
if fm.Recurrence != "" {
if parsed, ok := taskpkg.ParseRecurrence(fm.Recurrence); ok {
recurrence = parsed
}
}
// resolve type: missing defaults to first configured type,
// invalid non-empty type is a hard error
var taskType taskpkg.Type
if fm.Type == "" {
taskType = taskpkg.DefaultType()
} else {
var ok bool
taskType, ok = taskpkg.ParseType(fm.Type)
if !ok {
return nil, fmt.Errorf("invalid template type %q", fm.Type)
}
}
// second pass: extract custom fields from frontmatter map
var fmMap map[string]interface{}
if err := yaml.Unmarshal([]byte(frontmatter), &fmMap); err != nil {
return nil, nil
}
customFields, _, err := extractCustomFields(fmMap, templateSource)
if err != nil {
return nil, fmt.Errorf("template custom fields: %w", err)
}
return &taskpkg.Task{
Title: fm.Title,
Description: body,
Type: taskType,
Status: taskpkg.NormalizeStatus(fm.Status),
Tags: fm.Tags,
DependsOn: fm.DependsOn,
Due: dueTime,
Recurrence: recurrence,
Assignee: fm.Assignee,
Priority: fm.Priority,
Points: fm.Points,
CustomFields: customFields,
}, nil
}
// setAuthorFromGit best-effort populates CreatedBy using current git user.
func (s *TikiStore) setAuthorFromGit(task *taskpkg.Task) {
if task == nil || task.CreatedBy != "" {
return
}
name, email, err := s.GetCurrentUser()
if err != nil {
return
}
switch {
case name != "" && email != "":
task.CreatedBy = fmt.Sprintf("%s <%s>", name, email)
case name != "":
task.CreatedBy = name
case email != "":
task.CreatedBy = email
}
}
// NewTaskTemplate returns a new task populated with template defaults.
// The task will have all fields from the template (priority, type, tags, etc.)
// plus generated ID and git author.
func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Generate random ID with collision check
var taskID string
for {
randomID := config.GenerateRandomID()
taskID = fmt.Sprintf("TIKI-%s", randomID)
// Check if file already exists (collision check)
path := s.taskFilePath(taskID)
if _, err := os.Stat(path); os.IsNotExist(err) {
break // No collision, use this ID
}
slog.Debug("ID collision detected during template creation, regenerating", "id", taskID)
}
taskID = normalizeTaskID(taskID)
// Load template (with defaults)
template, err := loadTemplateTask()
if err != nil {
return nil, fmt.Errorf("loading template: %w", err)
}
// Create base task with defaults
task := &taskpkg.Task{
ID: taskID,
Title: "",
Description: "",
Status: taskpkg.DefaultStatus(),
Type: taskpkg.DefaultType(),
Priority: 3, // default: medium priority (1-5 scale)
Points: 0,
CreatedAt: time.Now(),
}
// Apply template values if available
if template != nil {
task.Title = template.Title
task.Description = template.Description
task.Type = template.Type
task.Priority = template.Priority
task.Points = template.Points
task.Tags = template.Tags
task.DependsOn = template.DependsOn
task.Due = template.Due
task.Recurrence = template.Recurrence
task.Assignee = template.Assignee
task.Status = template.Status
}
if template != nil && template.CustomFields != nil {
task.CustomFields = make(map[string]interface{}, len(template.CustomFields))
for k, v := range template.CustomFields {
if ss, ok := v.([]string); ok {
cp := make([]string, len(ss))
copy(cp, ss)
task.CustomFields[k] = cp
} else {
task.CustomFields[k] = v
}
}
}
// Ensure type has a value (fallback if template didn't provide)
if task.Type == "" {
task.Type = taskpkg.DefaultType()
}
// Ensure status has a value
if task.Status == "" {
task.Status = taskpkg.DefaultStatus()
}
// Set git author
s.setAuthorFromGit(task)
return task, nil
}