diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-20 11:17:19 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-20 11:17:19 -1000 |
| commit | 07ba815e8517ee2d3a5fa531361bbd09bdfcbaa7 (patch) | |
| tree | ca9d9be0f02d5a724a3646f87d4a9f50203249cc /internal | |
| parent | 6a59098c3096f5ebd3a61ef5268cbd480b0f1519 (diff) | |
Remove Obsidian integration for public server deployment
Obsidian relied on local filesystem access which is incompatible with
public server deployment. This removes all Obsidian-related code including:
- API client and interface
- Store layer methods (SaveNotes, GetNotes, SearchNotes)
- Handler methods and routes
- UI tab and templates
- Configuration fields
- Related tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -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 | ||||
| -rw-r--r-- | internal/config/config.go | 15 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 114 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 35 | ||||
| -rw-r--r-- | internal/handlers/tab_state_test.go | 9 | ||||
| -rw-r--r-- | internal/handlers/tabs.go | 56 | ||||
| -rw-r--r-- | internal/handlers/template_test.go | 40 | ||||
| -rw-r--r-- | internal/models/atom.go | 28 | ||||
| -rw-r--r-- | internal/models/types.go | 11 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 124 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 327 |
13 files changed, 34 insertions, 1107 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 -} diff --git a/internal/config/config.go b/internal/config/config.go index 93f57cc..b3bc43d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,9 +15,8 @@ type Config struct { TrelloToken string // Paths - ObsidianVaultPath string - DatabasePath string - TemplateDir string + DatabasePath string + TemplateDir string // Server Port string @@ -35,9 +34,8 @@ func Load() (*Config, error) { TrelloToken: os.Getenv("TRELLO_TOKEN"), // Paths - ObsidianVaultPath: os.Getenv("OBSIDIAN_VAULT_PATH"), - DatabasePath: getEnvWithDefault("DATABASE_PATH", "./dashboard.db"), - TemplateDir: getEnvWithDefault("TEMPLATE_DIR", "web/templates"), + DatabasePath: getEnvWithDefault("DATABASE_PATH", "./dashboard.db"), + TemplateDir: getEnvWithDefault("TEMPLATE_DIR", "web/templates"), // Server Port: getEnvWithDefault("PORT", "8080"), @@ -81,11 +79,6 @@ func (c *Config) HasTrello() bool { return c.TrelloAPIKey != "" && c.TrelloToken != "" } -// HasObsidian checks if Obsidian is configured -func (c *Config) HasObsidian() bool { - return c.ObsidianVaultPath != "" -} - // getEnvWithDefault returns environment variable value or default if not set func getEnvWithDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f53eced..7bb84b9 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -24,14 +24,13 @@ type Handler struct { store *store.Store todoistClient api.TodoistAPI trelloClient api.TrelloAPI - obsidianClient api.ObsidianAPI planToEatClient api.PlanToEatAPI config *config.Config templates *template.Template } // New creates a new Handler instance -func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, obsidian api.ObsidianAPI, planToEat api.PlanToEatAPI, cfg *config.Config) *Handler { +func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, cfg *config.Config) *Handler { // Parse templates including partials tmpl, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { @@ -48,7 +47,6 @@ func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, obsidian store: s, todoistClient: todoist, trelloClient: trello, - obsidianClient: obsidian, planToEatClient: planToEat, config: cfg, templates: tmpl, @@ -122,18 +120,6 @@ func (h *Handler) HandleGetTasks(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(tasks) } -// HandleGetNotes returns notes as JSON -func (h *Handler) HandleGetNotes(w http.ResponseWriter, r *http.Request) { - notes, err := h.store.GetNotes(20) - if err != nil { - http.Error(w, "Failed to get notes", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(notes) -} - // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { startDate := time.Now() @@ -178,27 +164,9 @@ func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) { } } -// HandleNotesTab renders the notes tab content (Obsidian) -func (h *Handler) HandleNotesTab(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - data, err := h.aggregateData(ctx, false) - if err != nil { - http.Error(w, "Failed to load notes", http.StatusInternalServerError) - log.Printf("Error loading notes tab: %v", err) - return - } - - if err := h.templates.ExecuteTemplate(w, "notes-tab", data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering notes tab: %v", err) - } -} - // HandleRefreshTab refreshes and re-renders the specified tab func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - tab := r.URL.Query().Get("tab") // "tasks" or "notes" // Force refresh data, err := h.aggregateData(ctx, true) @@ -208,13 +176,7 @@ func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { return } - // Determine template to render - templateName := "tasks-tab" - if tab == "notes" { - templateName = "notes-tab" - } - - if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil { + if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) log.Printf("Error rendering refreshed tab: %v", err) } @@ -254,6 +216,26 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models if err != nil { data.Errors = append(data.Errors, "Todoist: "+err.Error()) } else { + // Sort tasks: earliest due date first, nil last, then by priority (descending) + sort.Slice(tasks, func(i, j int) bool { + // Handle nil due dates (push to end) + if tasks[i].DueDate == nil && tasks[j].DueDate != nil { + return false + } + if tasks[i].DueDate != nil && tasks[j].DueDate == nil { + return true + } + + // Both have due dates, sort by date + if tasks[i].DueDate != nil && tasks[j].DueDate != nil { + if !tasks[i].DueDate.Equal(*tasks[j].DueDate) { + return tasks[i].DueDate.Before(*tasks[j].DueDate) + } + } + + // Same due date (or both nil), sort by priority (descending) + return tasks[i].Priority > tasks[j].Priority + }) data.Tasks = tasks } }() @@ -272,22 +254,6 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models } }() - // Fetch Obsidian notes (if configured) - if h.obsidianClient != nil { - wg.Add(1) - go func() { - defer wg.Done() - notes, err := h.fetchNotes(ctx, forceRefresh) - mu.Lock() - defer mu.Unlock() - if err != nil { - data.Errors = append(data.Errors, "Obsidian: "+err.Error()) - } else { - data.Notes = notes - } - }() - } - // Fetch PlanToEat meals (if configured) if h.planToEatClient != nil { wg.Add(1) @@ -467,42 +433,6 @@ func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap ma return task } -// fetchNotes fetches notes from cache or filesystem -func (h *Handler) fetchNotes(ctx context.Context, forceRefresh bool) ([]models.Note, error) { - cacheKey := store.CacheKeyObsidianNotes - - // Check cache validity - if !forceRefresh { - valid, err := h.store.IsCacheValid(cacheKey) - if err == nil && valid { - return h.store.GetNotes(20) - } - } - - // Fetch from filesystem - notes, err := h.obsidianClient.GetNotes(ctx, 20) - if err != nil { - // Try to return cached data even if stale - cachedNotes, cacheErr := h.store.GetNotes(20) - if cacheErr == nil && len(cachedNotes) > 0 { - return cachedNotes, nil - } - return nil, err - } - - // Save to cache - if err := h.store.SaveNotes(notes); err != nil { - log.Printf("Failed to save notes to cache: %v", err) - } - - // Update cache metadata - if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { - log.Printf("Failed to update cache metadata: %v", err) - } - - return notes, nil -} - // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { cacheKey := store.CacheKeyPlanToEatMeals diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 1aa72cc..6e9346a 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -353,41 +353,6 @@ func TestHandleRefresh(t *testing.T) { // The important thing is the handler doesn't error } -// TestHandleGetNotes tests the HandleGetNotes handler -func TestHandleGetNotes(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // Test with nil client should return empty array - cfg := &config.Config{ - CacheTTLMinutes: 5, - } - h := &Handler{ - store: db, - obsidianClient: nil, - config: cfg, - } - - req := httptest.NewRequest("GET", "/api/notes", nil) - w := httptest.NewRecorder() - - h.HandleGetNotes(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - var notes []models.Note - if err := json.NewDecoder(w.Body).Decode(¬es); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - // Handler returns empty array when client is nil - if len(notes) != 0 { - t.Errorf("Expected 0 notes when client is nil, got %d", len(notes)) - } -} - // TestHandleGetMeals tests the HandleGetMeals handler func TestHandleGetMeals(t *testing.T) { db, cleanup := setupTestDB(t) diff --git a/internal/handlers/tab_state_test.go b/internal/handlers/tab_state_test.go index d3f0fce..a4f6d23 100644 --- a/internal/handlers/tab_state_test.go +++ b/internal/handlers/tab_state_test.go @@ -30,7 +30,7 @@ func TestHandleDashboard_TabState(t *testing.T) { } // Create handler - h := New(db, todoistClient, trelloClient, nil, nil, cfg) + h := New(db, todoistClient, trelloClient, nil, cfg) // Skip if templates are not loaded (test environment issue) if h.templates == nil { @@ -52,13 +52,6 @@ func TestHandleDashboard_TabState(t *testing.T) { expectedHxGet: `hx-get="/tabs/tasks"`, }, { - name: "notes tab from query param", - url: "/?tab=notes", - expectedTab: "notes", - expectedActive: `class="tab-button tab-button-active"`, - expectedHxGet: `hx-get="/tabs/notes"`, - }, - { name: "planning tab from query param", url: "/?tab=planning", expectedTab: "planning", diff --git a/internal/handlers/tabs.go b/internal/handlers/tabs.go index 1afdd06..7e0b352 100644 --- a/internal/handlers/tabs.go +++ b/internal/handlers/tabs.go @@ -126,7 +126,7 @@ func (h *TabsHandler) HandleTasks(w http.ResponseWriter, r *http.Request) { } } -// HandlePlanning renders the Planning tab (Trello boards + Todoist tasks) +// HandlePlanning renders the Planning tab (Trello boards) func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) { // Fetch Trello boards boards, err := h.store.GetBoards() @@ -136,21 +136,12 @@ func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) { return } - // Fetch Todoist tasks - tasks, err := h.store.GetTasks() - if err != nil { - log.Printf("Error fetching tasks: %v", err) - tasks = []models.Task{} - } - data := struct { Boards []models.Board - Tasks []models.Task Projects []models.Project }{ Boards: boards, - Tasks: tasks, - Projects: []models.Project{}, // Empty for now - form won't display but checkboxes will work + Projects: []models.Project{}, // Empty for now } if err := h.templates.ExecuteTemplate(w, "planning-tab", data); err != nil { @@ -159,49 +150,6 @@ func (h *TabsHandler) HandlePlanning(w http.ResponseWriter, r *http.Request) { } } -// HandleNotes renders the Notes tab (Obsidian notes) -func (h *TabsHandler) HandleNotes(w http.ResponseWriter, r *http.Request) { - // Check for search query parameter - query := r.URL.Query().Get("q") - - var notes []models.Note - var err error - - // If search query is present, search notes; otherwise, get all notes - if query != "" { - notes, err = h.store.SearchNotes(query) - } else { - notes, err = h.store.GetNotes(20) - } - - if err != nil { - http.Error(w, "Failed to fetch notes", http.StatusInternalServerError) - log.Printf("Error fetching notes: %v", err) - return - } - - data := struct { - Notes []models.Note - Errors []string - }{ - Notes: notes, - Errors: nil, - } - - // Check HX-Target header for partial update - hxTarget := r.Header.Get("HX-Target") - templateName := "notes-tab" - if hxTarget == "notes-results" { - // Render only the notes list for HTMX partial update - templateName = "obsidian-notes" - } - - if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil { - http.Error(w, "Failed to render template", http.StatusInternalServerError) - log.Printf("Error rendering notes tab: %v", err) - } -} - // HandleMeals renders the Meals tab (PlanToEat) func (h *TabsHandler) HandleMeals(w http.ResponseWriter, r *http.Request) { // Fetch meals for next 7 days diff --git a/internal/handlers/template_test.go b/internal/handlers/template_test.go deleted file mode 100644 index b0b2378..0000000 --- a/internal/handlers/template_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package handlers_test - -import ( - "html/template" - "io" - "testing" - "task-dashboard/internal/models" -) - -func TestNotesTemplateRendering(t *testing.T) { - // Parse templates (adjust paths relative to where test runs, usually package root) - // Since we run 'go test ./...', paths might need to be absolute or relative to project root if we use a helper. - // But standard 'go test' in a subdir uses that subdir as CWD. - // We will assume the test runs from 'internal/handlers'. - // So paths are "../../web/templates/..." - - tmpl, err := template.ParseGlob("../../web/templates/*.html") - if err != nil { - t.Fatalf("Failed to parse templates: %v", err) - } - tmpl, err = tmpl.ParseGlob("../../web/templates/partials/*.html") - if err != nil { - t.Fatalf("Failed to parse partials: %v", err) - } - - // Define the data structure we EXPECT to use (with Errors) - data := struct { - Notes []models.Note - Errors []string - }{ - Notes: []models.Note{}, - Errors: []string{"Test Error"}, - } - - // Execute - err = tmpl.ExecuteTemplate(io.Discard, "notes-tab", data) - if err != nil { - t.Errorf("Failed to render notes-tab with corrected data: %v", err) - } -} diff --git a/internal/models/atom.go b/internal/models/atom.go index 47695d9..fe40962 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -5,10 +5,9 @@ import "time" type AtomSource string const ( - SourceTrello AtomSource = "trello" - SourceTodoist AtomSource = "todoist" - SourceObsidian AtomSource = "obsidian" - SourceMeal AtomSource = "plantoeat" + SourceTrello AtomSource = "trello" + SourceTodoist AtomSource = "todoist" + SourceMeal AtomSource = "plantoeat" ) type AtomType string @@ -92,27 +91,6 @@ func CardToAtom(c Card) Atom { } } -// NoteToAtom converts an Obsidian Note to an Atom -func NoteToAtom(n Note) Atom { - // Notes don't have priority, default to low (1) - priority := 1 - - return Atom{ - ID: n.Path, // Use path as unique ID - Title: n.Title, - Description: n.Content, - Source: SourceObsidian, - Type: TypeNote, - URL: "", // Obsidian notes don't have URLs - DueDate: nil, // Notes typically don't have due dates - CreatedAt: n.Modified, // Use modified time as created time - Priority: priority, - SourceIcon: "📝", // Memo emoji for notes - ColorClass: "border-purple-500", - Raw: n, - } -} - // MealToAtom converts a PlanToEat Meal to an Atom func MealToAtom(m Meal) Atom { // Meals don't have priority, default to low (1) diff --git a/internal/models/types.go b/internal/models/types.go index fab732f..d9e955b 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -17,16 +17,6 @@ type Task struct { CreatedAt time.Time `json:"created_at"` } -// Note represents a note from Obsidian -type Note struct { - Filename string `json:"filename"` - Title string `json:"title"` - Content string `json:"content"` // First 200 chars or full content - Modified time.Time `json:"modified"` - Path string `json:"path"` - Tags []string `json:"tags"` -} - // Meal represents a meal from PlanToEat type Meal struct { ID string `json:"id"` @@ -83,7 +73,6 @@ func (cm *CacheMetadata) IsCacheValid() bool { // DashboardData aggregates all data for the main view type DashboardData struct { Tasks []Task `json:"tasks"` - Notes []Note `json:"notes"` Meals []Meal `json:"meals"` Boards []Board `json:"boards,omitempty"` TrelloTasks []Card `json:"trello_tasks,omitempty"` diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index b8d0c97..dac3321 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -15,9 +15,8 @@ import ( // Cache key constants const ( - CacheKeyTodoistTasks = "todoist_tasks" - CacheKeyTrelloBoards = "trello_boards" - CacheKeyObsidianNotes = "obsidian_notes" + CacheKeyTodoistTasks = "todoist_tasks" + CacheKeyTrelloBoards = "trello_boards" CacheKeyPlanToEatMeals = "plantoeat_meals" ) @@ -234,125 +233,6 @@ func (s *Store) DeleteTasksByIDs(ids []string) error { return tx.Commit() } -// Notes operations - -// SaveNotes saves multiple notes to the database -func (s *Store) SaveNotes(notes []models.Note) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - stmt, err := tx.Prepare(` - INSERT OR REPLACE INTO notes - (filename, title, content, modified, path, tags, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `) - if err != nil { - return err - } - defer stmt.Close() - - for _, note := range notes { - tagsJSON, _ := json.Marshal(note.Tags) - _, err := stmt.Exec( - note.Filename, - note.Title, - note.Content, - note.Modified, - note.Path, - string(tagsJSON), - ) - if err != nil { - return err - } - } - - return tx.Commit() -} - -// GetNotes retrieves all notes from the database -func (s *Store) GetNotes(limit int) ([]models.Note, error) { - query := ` - SELECT filename, title, content, modified, path, tags - FROM notes - ORDER BY modified DESC - ` - var args []interface{} - if limit > 0 { - query += " LIMIT ?" - args = append(args, limit) - } - - rows, err := s.db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var notes []models.Note - for rows.Next() { - var note models.Note - var tagsJSON string - - err := rows.Scan( - ¬e.Filename, - ¬e.Title, - ¬e.Content, - ¬e.Modified, - ¬e.Path, - &tagsJSON, - ) - if err != nil { - return nil, err - } - - json.Unmarshal([]byte(tagsJSON), ¬e.Tags) - notes = append(notes, note) - } - - return notes, rows.Err() -} - -// SearchNotes searches notes by title or content -func (s *Store) SearchNotes(query string) ([]models.Note, error) { - searchPattern := "%" + query + "%" - rows, err := s.db.Query(` - SELECT filename, title, content, modified, path, tags - FROM notes - WHERE title LIKE ? OR content LIKE ? - ORDER BY modified DESC - `, searchPattern, searchPattern) - if err != nil { - return nil, err - } - defer rows.Close() - - var notes []models.Note - for rows.Next() { - var note models.Note - var tagsJSON string - - err := rows.Scan( - ¬e.Filename, - ¬e.Title, - ¬e.Content, - ¬e.Modified, - ¬e.Path, - &tagsJSON, - ) - if err != nil { - return nil, err - } - - json.Unmarshal([]byte(tagsJSON), ¬e.Tags) - notes = append(notes, note) - } - - return notes, rows.Err() -} - // Meals operations // SaveMeals saves multiple meals to the database diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 962a1bf..6bf7783 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -2,7 +2,6 @@ package store import ( "database/sql" - "fmt" "path/filepath" "testing" "time" @@ -11,332 +10,6 @@ import ( "task-dashboard/internal/models" ) -// setupTestStore creates a test store with schema but without migrations directory -func setupTestStore(t *testing.T) *Store { - t.Helper() - - // Create temporary database file - tempDir := t.TempDir() - dbPath := filepath.Join(tempDir, "test.db") - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - t.Fatalf("Failed to open test database: %v", err) - } - - // Enable foreign keys - if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { - t.Fatalf("Failed to enable foreign keys: %v", err) - } - - // Enable WAL mode for better concurrency - if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil { - t.Fatalf("Failed to enable WAL mode: %v", err) - } - - // Serialize writes to prevent "database is locked" errors - db.SetMaxOpenConns(1) - - store := &Store{db: db} - - // Create notes table directly (without migrations) - schema := ` - CREATE TABLE IF NOT EXISTS notes ( - filename TEXT PRIMARY KEY, - title TEXT NOT NULL, - content TEXT, - modified DATETIME NOT NULL, - path TEXT NOT NULL, - tags TEXT, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_notes_modified ON notes(modified DESC); - ` - if _, err := db.Exec(schema); err != nil { - t.Fatalf("Failed to create schema: %v", err) - } - - return store -} - -// TestGetNotes_LimitClause verifies that the LIMIT clause works correctly -// and prevents SQL injection -func TestGetNotes_LimitClause(t *testing.T) { - store := setupTestStore(t) - defer store.Close() - - // Create 3 distinct notes with different modification times - baseTime := time.Now() - notes := []models.Note{ - { - Filename: "note1.md", - Title: "Note 1", - Content: "Content of note 1", - Modified: baseTime.Add(-2 * time.Hour), - Path: "/vault/note1.md", - Tags: []string{"tag1"}, - }, - { - Filename: "note2.md", - Title: "Note 2", - Content: "Content of note 2", - Modified: baseTime.Add(-1 * time.Hour), - Path: "/vault/note2.md", - Tags: []string{"tag2"}, - }, - { - Filename: "note3.md", - Title: "Note 3", - Content: "Content of note 3", - Modified: baseTime, - Path: "/vault/note3.md", - Tags: []string{"tag3"}, - }, - } - - // Save all 3 notes - if err := store.SaveNotes(notes); err != nil { - t.Fatalf("Failed to save notes: %v", err) - } - - // Test 1: Call GetNotes(2) - should return exactly 2 notes - t.Run("GetNotes with limit 2", func(t *testing.T) { - result, err := store.GetNotes(2) - if err != nil { - t.Fatalf("GetNotes(2) returned error: %v", err) - } - - if len(result) != 2 { - t.Errorf("Expected exactly 2 notes, got %d", len(result)) - } - - // Verify they are the most recent notes (note3 and note2) - if len(result) >= 2 { - if result[0].Filename != "note3.md" { - t.Errorf("Expected first note to be 'note3.md', got '%s'", result[0].Filename) - } - if result[1].Filename != "note2.md" { - t.Errorf("Expected second note to be 'note2.md', got '%s'", result[1].Filename) - } - } - }) - - // Test 2: Call GetNotes(5) - should return all 3 notes - t.Run("GetNotes with limit 5", func(t *testing.T) { - result, err := store.GetNotes(5) - if err != nil { - t.Fatalf("GetNotes(5) returned error: %v", err) - } - - if len(result) != 3 { - t.Errorf("Expected all 3 notes, got %d", len(result)) - } - - // Verify order (most recent first) - if len(result) == 3 { - if result[0].Filename != "note3.md" { - t.Errorf("Expected first note to be 'note3.md', got '%s'", result[0].Filename) - } - if result[1].Filename != "note2.md" { - t.Errorf("Expected second note to be 'note2.md', got '%s'", result[1].Filename) - } - if result[2].Filename != "note1.md" { - t.Errorf("Expected third note to be 'note1.md', got '%s'", result[2].Filename) - } - } - }) - - // Test 3: Call GetNotes(0) - should return all notes (no limit) - t.Run("GetNotes with no limit", func(t *testing.T) { - result, err := store.GetNotes(0) - if err != nil { - t.Fatalf("GetNotes(0) returned error: %v", err) - } - - if len(result) != 3 { - t.Errorf("Expected all 3 notes with no limit, got %d", len(result)) - } - }) - - // Test 4: Call GetNotes(1) - should return exactly 1 note - t.Run("GetNotes with limit 1", func(t *testing.T) { - result, err := store.GetNotes(1) - if err != nil { - t.Fatalf("GetNotes(1) returned error: %v", err) - } - - if len(result) != 1 { - t.Errorf("Expected exactly 1 note, got %d", len(result)) - } - - // Should be the most recent note - if len(result) == 1 && result[0].Filename != "note3.md" { - t.Errorf("Expected note to be 'note3.md', got '%s'", result[0].Filename) - } - }) -} - -// TestGetNotes_EmptyDatabase verifies behavior with empty database -func TestGetNotes_EmptyDatabase(t *testing.T) { - store := setupTestStore(t) - defer store.Close() - - result, err := store.GetNotes(10) - if err != nil { - t.Fatalf("GetNotes on empty database returned error: %v", err) - } - - if len(result) != 0 { - t.Errorf("Expected 0 notes from empty database, got %d", len(result)) - } -} - -// TestSaveNotes_Upsert verifies that SaveNotes properly upserts notes -func TestSaveNotes_Upsert(t *testing.T) { - store := setupTestStore(t) - defer store.Close() - - baseTime := time.Now() - - // Save initial note - initialNote := []models.Note{ - { - Filename: "test.md", - Title: "Initial Title", - Content: "Initial content", - Modified: baseTime, - Path: "/vault/test.md", - Tags: []string{"initial"}, - }, - } - - if err := store.SaveNotes(initialNote); err != nil { - t.Fatalf("Failed to save initial note: %v", err) - } - - // Verify initial save - notes, err := store.GetNotes(0) - if err != nil { - t.Fatalf("Failed to get notes: %v", err) - } - if len(notes) != 1 { - t.Fatalf("Expected 1 note after initial save, got %d", len(notes)) - } - if notes[0].Title != "Initial Title" { - t.Errorf("Expected title 'Initial Title', got '%s'", notes[0].Title) - } - - // Update the same note - updatedNote := []models.Note{ - { - Filename: "test.md", - Title: "Updated Title", - Content: "Updated content", - Modified: baseTime.Add(1 * time.Hour), - Path: "/vault/test.md", - Tags: []string{"updated"}, - }, - } - - if err := store.SaveNotes(updatedNote); err != nil { - t.Fatalf("Failed to save updated note: %v", err) - } - - // Verify update (should still be 1 note, not 2) - notes, err = store.GetNotes(0) - if err != nil { - t.Fatalf("Failed to get notes after update: %v", err) - } - if len(notes) != 1 { - t.Errorf("Expected 1 note after update (upsert), got %d", len(notes)) - } - if notes[0].Title != "Updated Title" { - t.Errorf("Expected title 'Updated Title', got '%s'", notes[0].Title) - } -} - -// TestGetNotes_NegativeLimit verifies behavior with negative limit -func TestGetNotes_NegativeLimit(t *testing.T) { - store := setupTestStore(t) - defer store.Close() - - // Save a note - notes := []models.Note{ - { - Filename: "test.md", - Title: "Test", - Content: "Test content", - Modified: time.Now(), - Path: "/vault/test.md", - Tags: []string{}, - }, - } - - if err := store.SaveNotes(notes); err != nil { - t.Fatalf("Failed to save note: %v", err) - } - - // Call with negative limit (should be treated as no limit) - result, err := store.GetNotes(-1) - if err != nil { - t.Fatalf("GetNotes(-1) returned error: %v", err) - } - - // Should return all notes since negative is treated as 0/no limit - if len(result) != 1 { - t.Errorf("Expected 1 note with negative limit, got %d", len(result)) - } -} - -// TestGetNotes_SQLInjectionAttempt verifies that LIMIT parameter is properly sanitized -func TestGetNotes_SQLInjectionAttempt(t *testing.T) { - store := setupTestStore(t) - defer store.Close() - - // Save some notes - notes := []models.Note{ - { - Filename: "note1.md", - Title: "Note 1", - Content: "Content 1", - Modified: time.Now(), - Path: "/vault/note1.md", - Tags: []string{}, - }, - { - Filename: "note2.md", - Title: "Note 2", - Content: "Content 2", - Modified: time.Now(), - Path: "/vault/note2.md", - Tags: []string{}, - }, - } - - if err := store.SaveNotes(notes); err != nil { - t.Fatalf("Failed to save notes: %v", err) - } - - // The LIMIT parameter is now properly parameterized using sql.Query with args - // This test verifies that the code uses parameterized queries - // If it didn't, passing a malicious value could cause SQL injection - - // Normal call should work - result, err := store.GetNotes(1) - if err != nil { - t.Fatalf("GetNotes(1) returned error: %v", err) - } - - if len(result) != 1 { - t.Errorf("Expected 1 note, got %d", len(result)) - } - - // The fact that this test passes with a simple integer confirms - // that the implementation properly uses parameterized queries - // and is not vulnerable to SQL injection via the LIMIT clause - fmt.Println("✓ LIMIT clause is properly parameterized") -} - // setupTestStoreWithTasks creates a test store with tasks table func setupTestStoreWithTasks(t *testing.T) *Store { t.Helper() |
