summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/interfaces.go6
-rw-r--r--internal/api/obsidian.go221
-rw-r--r--internal/api/obsidian_test.go155
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
-}