package api import ( "bufio" "context" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "task-dashboard/internal/models" ) // ObsidianClient handles reading notes from an Obsidian vault type ObsidianClient struct { vaultPath string } // NewObsidianClient creates a new Obsidian vault reader func NewObsidianClient(vaultPath string) *ObsidianClient { return &ObsidianClient{ vaultPath: vaultPath, } } // fileInfo holds file metadata for sorting type fileInfo struct { path string modTime time.Time } // GetNotes reads and returns the most recently modified notes from the vault func (c *ObsidianClient) GetNotes(ctx context.Context, limit int) ([]models.Note, error) { if c.vaultPath == "" { return nil, fmt.Errorf("obsidian vault path not configured") } // Check if vault path exists if _, err := os.Stat(c.vaultPath); os.IsNotExist(err) { return nil, fmt.Errorf("vault path does not exist: %s", c.vaultPath) } // Collect all markdown files with their modification times var files []fileInfo err := filepath.Walk(c.vaultPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip files we can't access } // Skip symbolic links to prevent path traversal if info.Mode()&os.ModeSymlink != 0 { return nil } // Skip directories and non-markdown files if info.IsDir() || !strings.HasSuffix(info.Name(), ".md") { return nil } // Skip hidden files and directories if strings.HasPrefix(info.Name(), ".") { return nil } files = append(files, fileInfo{ path: path, modTime: info.ModTime(), }) return nil }) if err != nil { return nil, fmt.Errorf("failed to walk vault directory: %w", err) } // Sort by modification time (most recent first) sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) }) // Limit the number of files to process if limit > 0 && len(files) > limit { files = files[:limit] } // Parse each file notes := make([]models.Note, 0, len(files)) for _, file := range files { note, err := c.parseMarkdownFile(file.path, file.modTime) if err != nil { // Skip files that fail to parse continue } notes = append(notes, *note) } return notes, nil } // parseMarkdownFile reads and parses a markdown file func (c *ObsidianClient) parseMarkdownFile(path string, modTime time.Time) (*models.Note, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() scanner := bufio.NewScanner(file) var content strings.Builder var tags []string inFrontmatter := false lineCount := 0 // Parse file for scanner.Scan() { line := scanner.Text() lineCount++ // Check for YAML frontmatter if lineCount == 1 && line == "---" { inFrontmatter = true continue } if inFrontmatter { if line == "---" { inFrontmatter = false continue } // Extract tags from frontmatter if strings.HasPrefix(line, "tags:") { tagsStr := strings.TrimPrefix(line, "tags:") tagsStr = strings.Trim(tagsStr, " []") if tagsStr != "" { tags = strings.Split(tagsStr, ",") for i, tag := range tags { tags[i] = strings.TrimSpace(tag) } } } continue } // Add to content (limit to preview) if content.Len() < 500 { // Limit to ~500 chars content.WriteString(line) content.WriteString("\n") } } if err := scanner.Err(); err != nil { return nil, err } // Extract inline tags (e.g., #tag) inlineTags := extractInlineTags(content.String()) tags = append(tags, inlineTags...) tags = uniqueStrings(tags) // Get filename and title filename := filepath.Base(path) title := strings.TrimSuffix(filename, ".md") // Try to extract title from first H1 heading contentStr := content.String() h1Regex := regexp.MustCompile(`^#\s+(.+)$`) lines := strings.Split(contentStr, "\n") for _, line := range lines { if matches := h1Regex.FindStringSubmatch(line); len(matches) > 1 { title = matches[1] break } } note := &models.Note{ Filename: filename, Title: title, Content: strings.TrimSpace(contentStr), Modified: modTime, Path: path, Tags: tags, } return note, nil } // extractInlineTags finds all #tags in the content func extractInlineTags(content string) []string { tagRegex := regexp.MustCompile(`#([a-zA-Z0-9_-]+)`) matches := tagRegex.FindAllStringSubmatch(content, -1) tags := make([]string, 0, len(matches)) for _, match := range matches { if len(match) > 1 { tags = append(tags, match[1]) } } return tags } // uniqueStrings returns a slice with duplicate strings removed func uniqueStrings(slice []string) []string { seen := make(map[string]bool) result := make([]string, 0, len(slice)) for _, item := range slice { if !seen[item] && item != "" { seen[item] = true result = append(result, item) } } return result }