diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/interfaces.go | 6 | ||||
| -rw-r--r-- | internal/api/obsidian.go | 221 | ||||
| -rw-r--r-- | internal/api/obsidian_test.go | 155 |
3 files changed, 0 insertions, 382 deletions
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 2419707..33bef59 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -26,11 +26,6 @@ type TrelloAPI interface { UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error } -// ObsidianAPI defines the interface for Obsidian operations -type ObsidianAPI interface { - GetNotes(ctx context.Context, limit int) ([]models.Note, error) -} - // PlanToEatAPI defines the interface for PlanToEat operations type PlanToEatAPI interface { GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) @@ -42,6 +37,5 @@ type PlanToEatAPI interface { var ( _ TodoistAPI = (*TodoistClient)(nil) _ TrelloAPI = (*TrelloClient)(nil) - _ ObsidianAPI = (*ObsidianClient)(nil) _ PlanToEatAPI = (*PlanToEatClient)(nil) ) diff --git a/internal/api/obsidian.go b/internal/api/obsidian.go deleted file mode 100644 index 413fdd3..0000000 --- a/internal/api/obsidian.go +++ /dev/null @@ -1,221 +0,0 @@ -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 -} diff --git a/internal/api/obsidian_test.go b/internal/api/obsidian_test.go deleted file mode 100644 index 3509594..0000000 --- a/internal/api/obsidian_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package api - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -// TestGetNotes_SymlinkSecurity verifies that GetNotes does not follow symlinks -// to prevent path traversal attacks -func TestGetNotes_SymlinkSecurity(t *testing.T) { - // Create temporary directories - tempDir := t.TempDir() - vaultDir := filepath.Join(tempDir, "vault") - outsideDir := filepath.Join(tempDir, "outside") - - // Create vault and outside directories - if err := os.Mkdir(vaultDir, 0755); err != nil { - t.Fatalf("Failed to create vault directory: %v", err) - } - if err := os.Mkdir(outsideDir, 0755); err != nil { - t.Fatalf("Failed to create outside directory: %v", err) - } - - // Create a valid markdown file inside the vault - validFile := filepath.Join(vaultDir, "valid-note.md") - validContent := "# Valid Note\n\nThis is a valid note inside the vault." - if err := os.WriteFile(validFile, []byte(validContent), 0644); err != nil { - t.Fatalf("Failed to create valid markdown file: %v", err) - } - - // Create a secret file outside the vault - secretFile := filepath.Join(outsideDir, "secret.txt") - secretContent := "This is a secret file outside the vault that should not be accessible." - if err := os.WriteFile(secretFile, []byte(secretContent), 0644); err != nil { - t.Fatalf("Failed to create secret file: %v", err) - } - - // Create a symlink inside the vault pointing to the secret file - symlinkPath := filepath.Join(vaultDir, "symlink-note.md") - if err := os.Symlink(secretFile, symlinkPath); err != nil { - t.Skipf("Skipping test: unable to create symlink (may not be supported on this system): %v", err) - } - - // Initialize Obsidian client - client := NewObsidianClient(vaultDir) - - // Call GetNotes - ctx := context.Background() - notes, err := client.GetNotes(ctx, 10) - if err != nil { - t.Fatalf("GetNotes returned error: %v", err) - } - - // Verify that only the valid note is returned - if len(notes) != 1 { - t.Errorf("Expected 1 note, got %d notes", len(notes)) - for i, note := range notes { - t.Logf("Note %d: %s (path: %s)", i, note.Filename, note.Path) - } - t.Fatalf("Test failed: symlink was followed or wrong number of notes returned") - } - - // Verify the returned note is the valid one - note := notes[0] - if note.Filename != "valid-note.md" { - t.Errorf("Expected filename 'valid-note.md', got '%s'", note.Filename) - } - if note.Title != "Valid Note" { - t.Errorf("Expected title 'Valid Note', got '%s'", note.Title) - } - - // Ensure the content does not contain the secret text - if containsString(note.Content, "secret") { - t.Errorf("Note content contains 'secret', which suggests symlink was followed: %s", note.Content) - } -} - -// TestGetNotes_BasicFunctionality tests basic GetNotes functionality -func TestGetNotes_BasicFunctionality(t *testing.T) { - // Create temporary vault directory - vaultDir := t.TempDir() - - // Create multiple markdown files with different modification times - files := []struct { - name string - content string - delay time.Duration - }{ - {"oldest.md", "# Oldest Note\n\nThis is the oldest note.", 0}, - {"middle.md", "# Middle Note\n\nThis is a middle note.", 10 * time.Millisecond}, - {"newest.md", "# Newest Note\n\nThis is the newest note.", 20 * time.Millisecond}, - } - - for _, file := range files { - time.Sleep(file.delay) - path := filepath.Join(vaultDir, file.name) - if err := os.WriteFile(path, []byte(file.content), 0644); err != nil { - t.Fatalf("Failed to create file %s: %v", file.name, err) - } - } - - // Initialize Obsidian client - client := NewObsidianClient(vaultDir) - - // Test with limit - ctx := context.Background() - notes, err := client.GetNotes(ctx, 2) - if err != nil { - t.Fatalf("GetNotes returned error: %v", err) - } - - // Should return 2 most recent notes - if len(notes) != 2 { - t.Errorf("Expected 2 notes with limit, got %d", len(notes)) - } - - // Verify order (newest first) - if len(notes) >= 2 { - if notes[0].Filename != "newest.md" { - t.Errorf("Expected first note to be 'newest.md', got '%s'", notes[0].Filename) - } - if notes[1].Filename != "middle.md" { - t.Errorf("Expected second note to be 'middle.md', got '%s'", notes[1].Filename) - } - } - - // Test without limit - allNotes, err := client.GetNotes(ctx, 0) - if err != nil { - t.Fatalf("GetNotes without limit returned error: %v", err) - } - - if len(allNotes) != 3 { - t.Errorf("Expected 3 notes without limit, got %d", len(allNotes)) - } -} - -// containsString checks if haystack contains needle (case-insensitive) -func containsString(haystack, needle string) bool { - return len(haystack) > 0 && len(needle) > 0 && - (haystack == needle || len(haystack) >= len(needle) && - hasSubstring(haystack, needle)) -} - -func hasSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} |
