summaryrefslogtreecommitdiff
path: root/internal/api/obsidian.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/obsidian.go')
-rw-r--r--internal/api/obsidian.go216
1 files changed, 216 insertions, 0 deletions
diff --git a/internal/api/obsidian.go b/internal/api/obsidian.go
new file mode 100644
index 0000000..a8ba80d
--- /dev/null
+++ b/internal/api/obsidian.go
@@ -0,0 +1,216 @@
+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 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
+}