From 3591ad9bd8c33cb16c82169be69221b55b6e9dd0 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 23 Aug 2025 12:17:44 +0000 Subject: [PATCH] feat: note model --- ROADMAP.md | 7 +++++++ internal/models/models.go | 43 +++++++++++++++++++++++++++++++++++++++++++ internal/models/models_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- internal/store/migration_test.go | 4 ++-- internal/store/sql/migrations/0002_create_notes_table_down.sql | 2 ++ internal/store/sql/migrations/0002_create_notes_table_up.sql | 16 ++++++++++++++++ 6 file(s) changed, 215 insertion(s)(+), 5 deletion(s)(-) diff --git a/ROADMAP.md b/ROADMAP.md --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,3 +72,10 @@ - `read|view` - Displays formatted note content with syntax highlighting - `edit|update` - Opens configured editor OR Replaces note content with new markdown file - `remove|rm|delete|del` - Permanently removes the note file and metadata + +- `search` - Search notes by content, title, or tags +- `tag` - Add/remove tags from notes +- `recent` - Show recently created/modified notes +- `templates` - Create notes from predefined templates +- `archive` - Archive old notes +- `export` - Export notes to various formats diff --git a/internal/models/models.go b/internal/models/models.go --- a/internal/models/models.go +++ b/internal/models/models.go @@ -94,6 +94,18 @@ Finished *time.Time `json:"finished,omitempty"` } +// Note represents a markdown note +type Note struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags,omitempty"` + Archived bool `json:"archived"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + FilePath string `json:"file_path,omitempty"` +} + // MarshalTags converts tags slice to JSON string for database storage func (t *Task) MarshalTags() (string, error) { if len(t.Tags) == 0 { @@ -226,3 +238,34 @@ func (b *Book) SetCreatedAt(time time.Time) { b.Added = time } func (b *Book) GetUpdatedAt() time.Time { return b.Added } func (b *Book) SetUpdatedAt(time time.Time) { b.Added = time } + +// MarshalTags converts tags slice to JSON string for database storage +func (n *Note) MarshalTags() (string, error) { + if len(n.Tags) == 0 { + return "", nil + } + data, err := json.Marshal(n.Tags) + return string(data), err +} + +// UnmarshalTags converts JSON string from database to tags slice +func (n *Note) UnmarshalTags(data string) error { + if data == "" { + n.Tags = nil + return nil + } + return json.Unmarshal([]byte(data), &n.Tags) +} + +// IsArchived returns true if the note is archived +func (n *Note) IsArchived() bool { + return n.Archived +} + +func (n *Note) GetID() int64 { return n.ID } +func (n *Note) SetID(id int64) { n.ID = id } +func (n *Note) GetTableName() string { return "notes" } +func (n *Note) GetCreatedAt() time.Time { return n.Created } +func (n *Note) SetCreatedAt(time time.Time) { n.Created = time } +func (n *Note) GetUpdatedAt() time.Time { return n.Modified } +func (n *Note) SetUpdatedAt(time time.Time) { n.Modified = time } diff --git a/internal/models/models_test.go b/internal/models/models_test.go --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -533,6 +533,142 @@ }) }) + t.Run("Note Model", func(t *testing.T) { + t.Run("Model Interface Implementation", func(t *testing.T) { + note := &Note{ + ID: 1, + Title: "Test Note", + Content: "This is test content", + Created: time.Now(), + } + + if note.GetID() != 1 { + t.Errorf("Expected ID 1, got %d", note.GetID()) + } + + note.SetID(2) + if note.GetID() != 2 { + t.Errorf("Expected ID 2 after SetID, got %d", note.GetID()) + } + + if note.GetTableName() != "notes" { + t.Errorf("Expected table name 'notes', got '%s'", note.GetTableName()) + } + + createdAt := time.Now() + note.SetCreatedAt(createdAt) + if !note.GetCreatedAt().Equal(createdAt) { + t.Errorf("Expected created at %v, got %v", createdAt, note.GetCreatedAt()) + } + + updatedAt := time.Now().Add(time.Hour) + note.SetUpdatedAt(updatedAt) + if !note.GetUpdatedAt().Equal(updatedAt) { + t.Errorf("Expected updated at %v, got %v", updatedAt, note.GetUpdatedAt()) + } + }) + + t.Run("Archive Methods", func(t *testing.T) { + note := &Note{Archived: false} + + if note.IsArchived() { + t.Error("Note should not be archived") + } + + note.Archived = true + if !note.IsArchived() { + t.Error("Note should be archived") + } + }) + + t.Run("Tags Marshaling", func(t *testing.T) { + note := &Note{} + + result, err := note.MarshalTags() + if err != nil { + t.Fatalf("MarshalTags failed: %v", err) + } + if result != "" { + t.Errorf("Expected empty string for empty tags, got '%s'", result) + } + + note.Tags = []string{"personal", "work", "idea"} + result, err = note.MarshalTags() + if err != nil { + t.Fatalf("MarshalTags failed: %v", err) + } + + expected := `["personal","work","idea"]` + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + + newNote := &Note{} + err = newNote.UnmarshalTags(result) + if err != nil { + t.Fatalf("UnmarshalTags failed: %v", err) + } + + if len(newNote.Tags) != 3 { + t.Errorf("Expected 3 tags, got %d", len(newNote.Tags)) + } + if newNote.Tags[0] != "personal" || newNote.Tags[1] != "work" || newNote.Tags[2] != "idea" { + t.Errorf("Tags not unmarshaled correctly: %v", newNote.Tags) + } + + emptyNote := &Note{} + err = emptyNote.UnmarshalTags("") + if err != nil { + t.Fatalf("UnmarshalTags with empty string failed: %v", err) + } + if emptyNote.Tags != nil { + t.Error("Expected nil tags for empty string") + } + }) + + t.Run("JSON Marshaling", func(t *testing.T) { + now := time.Now() + modified := now.Add(time.Hour) + note := &Note{ + ID: 1, + Title: "Test Note", + Content: "This is test content with **markdown**", + Tags: []string{"personal", "markdown"}, + Archived: false, + Created: now, + Modified: modified, + FilePath: "/path/to/note.md", + } + + data, err := json.Marshal(note) + if err != nil { + t.Fatalf("JSON marshal failed: %v", err) + } + + var unmarshaled Note + err = json.Unmarshal(data, &unmarshaled) + if err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + if unmarshaled.ID != note.ID { + t.Errorf("Expected ID %d, got %d", note.ID, unmarshaled.ID) + } + if unmarshaled.Title != note.Title { + t.Errorf("Expected title %s, got %s", note.Title, unmarshaled.Title) + } + if unmarshaled.Content != note.Content { + t.Errorf("Expected content %s, got %s", note.Content, unmarshaled.Content) + } + if unmarshaled.Archived != note.Archived { + t.Errorf("Expected archived %v, got %v", note.Archived, unmarshaled.Archived) + } + if unmarshaled.FilePath != note.FilePath { + t.Errorf("Expected file path %s, got %s", note.FilePath, unmarshaled.FilePath) + } + }) + }) + t.Run("Interface Implementations", func(t *testing.T) { t.Run("All models implement Model interface", func(t *testing.T) { var models []Model @@ -541,11 +677,12 @@ movie := &Movie{} tvShow := &TVShow{} book := &Book{} + note := &Note{} - models = append(models, task, movie, tvShow, book) + models = append(models, task, movie, tvShow, book, note) - if len(models) != 4 { - t.Errorf("Expected 4 models, got %d", len(models)) + if len(models) != 5 { + t.Errorf("Expected 5 models, got %d", len(models)) } // Test that all models have the required methods @@ -627,6 +764,7 @@ movie := &Movie{} tvShow := &TVShow{} book := &Book{} + note := &Note{} // Test that zero values don't cause panics if task.IsCompleted() || task.IsPending() || task.IsDeleted() { @@ -647,6 +785,10 @@ if book.ProgressPercent() != 0 { t.Errorf("Zero value book should have 0%% progress, got %d%%", book.ProgressPercent()) + } + + if note.IsArchived() { + t.Error("Zero value note should not be archived") } }) }) diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go --- a/internal/store/migration_test.go +++ b/internal/store/migration_test.go @@ -112,7 +112,7 @@ t.Fatalf("RunMigrations failed: %v", err) } - expectedTables := []string{"migrations", "tasks", "movies", "tv_shows", "books"} + expectedTables := []string{"migrations", "tasks", "movies", "tv_shows", "books", "notes"} for _, tableName := range expectedTables { var count int @@ -354,7 +354,7 @@ t.Error("No migrations were applied") } - tables := []string{"tasks", "movies", "tv_shows", "books"} + tables := []string{"tasks", "movies", "tv_shows", "books", "notes"} for _, table := range tables { var count int err = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) diff --git a/internal/store/sql/migrations/0002_create_notes_table_down.sql b/internal/store/sql/migrations/0002_create_notes_table_down.sql new file mode 100644 --- /dev/null +++ b/internal/store/sql/migrations/0002_create_notes_table_down.sql @@ -0,0 +1,2 @@ +-- Drop notes table +DROP TABLE IF EXISTS notes; \ No newline at end of file diff --git a/internal/store/sql/migrations/0002_create_notes_table_up.sql b/internal/store/sql/migrations/0002_create_notes_table_up.sql new file mode 100644 --- /dev/null +++ b/internal/store/sql/migrations/0002_create_notes_table_up.sql @@ -0,0 +1,16 @@ +-- Notes table +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT, -- JSON array + archived BOOLEAN DEFAULT FALSE, + created DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP, + file_path TEXT -- optional path to source markdown file +); + +CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title); +CREATE INDEX IF NOT EXISTS idx_notes_archived ON notes(archived); +CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created); +CREATE INDEX IF NOT EXISTS idx_notes_modified ON notes(modified); \ No newline at end of file -- tangled.sh