diff --git a/.cursor/rules/pre-commit-checks.mdc b/.cursor/rules/pre-commit-checks.mdc new file mode 100644 index 0000000..42a544c --- /dev/null +++ b/.cursor/rules/pre-commit-checks.mdc @@ -0,0 +1,62 @@ +--- +description: Always run pre-commit checks before committing changes +globs: **/* +--- + +Before committing changes: + +1. Run pre-commit checks: + ```bash + ./scripts/pre-commit.sh + ``` + +2. Frontend checks must pass: + - Code formatting (bun format) + - Linting (bun lint) + - Type checking (bun check) + - Build verification (bun run build) + +3. Backend checks must pass: + - Go tests (go test -v ./...) + - Go linting (golangci-lint run) + +4. All checks must pass before committing: + - Fix any formatting issues + - Address all linting errors + - Fix type errors + - Fix failing tests + - Fix build errors + +5. Do not bypass checks: + - Never use git commit --no-verify + - Fix issues rather than skipping checks + - Keep the codebase clean and consistent + +metadata: + priority: high + version: 1.0 + + +examples: + - input: | + # Bad: Bypassing checks + git commit --no-verify -m "quick fix" + output: | + # Good: Run checks and fix issues + ./scripts/pre-commit.sh + # Fix any issues + git add . + git commit -m "fix: resolve linting issues" + + - input: | + # Bad: Ignoring failing checks + # Checks failed but commit anyway + output: | + # Good: Address all issues + ./scripts/pre-commit.sh + # Fix frontend formatting + bun format + # Fix Go lint issues + # Fix failing tests + ./scripts/pre-commit.sh # Run again to verify + git commit \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..290ba07 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +linters: + enable: + - gofmt + - govet + - gosimple + - staticcheck + - unused + - misspell + - goimports + - ineffassign + - gocritic + - errcheck + +run: + deadline: 5m + tests: true + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + +output: + formats: + - format: colored-line-number + print-issued-lines: true + print-linter-name: true \ No newline at end of file diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index a0dd521..2de029b 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -53,8 +53,10 @@ function createNotesStore() { createdNote.createdAt = new Date(createdNote.createdAt); createdNote.updatedAt = new Date(createdNote.updatedAt); - // Update local store with the server response - innerUpdate((notes) => [...notes, createdNote]); + // Reload all notes to get updated link information + await notes.load(); + + return createdNote; }, update: async (id: string, content: Partial) => { const response = await fetch(`/api/notes/${id}`, { diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte index 004e79b..40929cc 100644 --- a/frontend/src/routes/notes/[id]/+page.svelte +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { renderMarkdown } from '$lib/markdown'; + import Navigation from '$lib/components/Navigation.svelte'; interface Note { id: string; @@ -44,77 +45,93 @@ content: editedContent }); isEditing = false; + // Reload all notes to get updated link information + await notes.load(); } catch (error) { console.error('Failed to update note:', error); } } + + {#if note}
- {#if isEditing} -
-
- -
-
-
-
- -
-
-
-
- -
-
- -
-
- {:else} -
-

{note.title}

- {#await renderMarkdown(note.content, note.linksTo || [])} -

Loading...

- {:then html} - - {@html html} - {/await} -
-
- +
+
+
+
+

{note.title}

- {/if} - - {#if (note.linksTo || []).length > 0} -
-

Links to:

-
- {#each note.linksTo || [] as link} - {link.title} - {/each} + {#if isEditing} +
+
+ +
-
- {/if} - - {#if (note.linkedBy || []).length > 0} -
-

Referenced by:

-
- {#each note.linkedBy || [] as link} - {link.title} - {/each} +
+
+ +
-
- {/if} +
+
+ +
+
+ +
+
+ {:else} +
+ {#await renderMarkdown(note.content, note.linksTo || [])} +

Loading...

+ {:then html} + + {@html html} + {/await} +
+
+ +
+
+
+ {/if} + + {#if (note.linksTo || []).length > 0} +
+

Links to:

+
+ {#each note.linksTo || [] as link} + {link.title} + {/each} +
+
+ {/if} + + {#if (note.linkedBy || []).length > 0} +
+

Referenced by:

+
+ {#each note.linkedBy || [] as link} + {link.title} + {/each} +
+
+ {/if} +
{/if} diff --git a/frontend/src/routes/notes/new/+page.svelte b/frontend/src/routes/notes/new/+page.svelte index 5967783..5625c1a 100644 --- a/frontend/src/routes/notes/new/+page.svelte +++ b/frontend/src/routes/notes/new/+page.svelte @@ -1,8 +1,8 @@ + +
-

New Note

+
+
+
+

New Note

+
+
+
+
diff --git a/main.go b/main.go index 9d3820d..66edb4d 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,11 @@ func main() { // Create Gin router r := gin.Default() + // Trust only loopback addresses + if err := r.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil { + log.Fatal(err) + } + // API routes api := r.Group("/api") { diff --git a/notes/model.go b/notes/model.go index 055fd40..dee2c45 100644 --- a/notes/model.go +++ b/notes/model.go @@ -16,8 +16,8 @@ type Note struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` // Link relationships - LinksTo []*Note `json:"linksTo,omitempty" gorm:"many2many:note_links;joinForeignKey:source_note_id;joinReferences:target_note_id"` - LinkedBy []*Note `json:"linkedBy,omitempty" gorm:"many2many:note_links;joinForeignKey:target_note_id;joinReferences:source_note_id"` + LinksTo []*Note `json:"linksTo,omitempty" gorm:"many2many:note_links;joinForeignKey:source_note_id;joinReferences:target_note_id"` + LinkedBy []*Note `json:"linkedBy,omitempty" gorm:"many2many:note_links;joinForeignKey:target_note_id;joinReferences:source_note_id"` } // NoteLink represents a connection between two notes @@ -69,4 +69,4 @@ func (n *Note) UpdateLinks(db *gorm.DB) error { } return nil -} \ No newline at end of file +} diff --git a/notes/model_test.go b/notes/model_test.go index b2cbb1a..112e3f5 100644 --- a/notes/model_test.go +++ b/notes/model_test.go @@ -183,4 +183,4 @@ func TestUpdateLinks(t *testing.T) { t.Errorf("Expected 1 link, got %d", len(links)) } }) -} \ No newline at end of file +} diff --git a/notes/routes.go b/notes/routes.go index 71236f9..584ee02 100644 --- a/notes/routes.go +++ b/notes/routes.go @@ -120,4 +120,4 @@ func (h *Handler) handleReset(c *gin.Context) { return } c.Status(http.StatusOK) -} \ No newline at end of file +} diff --git a/notes/routes_test.go b/notes/routes_test.go index 4ab5bc4..f72937e 100644 --- a/notes/routes_test.go +++ b/notes/routes_test.go @@ -229,4 +229,4 @@ func TestHandler_ResetNotes(t *testing.T) { if len(response) != 0 { t.Errorf("Expected empty notes list, got %d notes", len(response)) } -} \ No newline at end of file +} diff --git a/notes/service.go b/notes/service.go index 5241e15..a61515e 100644 --- a/notes/service.go +++ b/notes/service.go @@ -27,11 +27,23 @@ func (s *Service) Create(note *Note) error { return fmt.Errorf("failed to create note: %w", err) } - // Update links + // Update links in this note if err := note.UpdateLinks(tx); err != nil { return fmt.Errorf("failed to update note links: %w", err) } + // Find and update notes that link to this note's title + var notesToUpdate []Note + if err := tx.Where("content LIKE ?", "%[["+note.Title+"]]%").Find(¬esToUpdate).Error; err != nil { + return fmt.Errorf("failed to find notes linking to %q: %w", note.Title, err) + } + + for _, n := range notesToUpdate { + if err := n.UpdateLinks(tx); err != nil { + return fmt.Errorf("failed to update links in note %q: %w", n.Title, err) + } + } + return nil }) @@ -50,7 +62,14 @@ func (s *Service) Create(note *Note) error { // Get retrieves a note by ID func (s *Service) Get(id string) (*Note, error) { var note Note - if err := s.db.Preload("LinksTo").Preload("LinkedBy").First(¬e, "id = ?", id).Error; err != nil { + if err := s.db. + Preload("LinksTo", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "title") + }). + Preload("LinkedBy", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "title") + }). + First(¬e, "id = ?", id).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } @@ -62,7 +81,15 @@ func (s *Service) Get(id string) (*Note, error) { // List retrieves all notes func (s *Service) List() ([]Note, error) { var notes []Note - if err := s.db.Preload("LinksTo").Order("updated_at desc").Find(¬es).Error; err != nil { + if err := s.db. + Preload("LinksTo", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "title") + }). + Preload("LinkedBy", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "title") + }). + Order("updated_at desc"). + Find(¬es).Error; err != nil { return nil, fmt.Errorf("failed to list notes: %w", err) } return notes, nil @@ -105,4 +132,4 @@ func (s *Service) Reset() error { return fmt.Errorf("failed to reset notes: %w", err) } return nil -} \ No newline at end of file +} diff --git a/notes/service_test.go b/notes/service_test.go index 0295cc1..baf8f7e 100644 --- a/notes/service_test.go +++ b/notes/service_test.go @@ -57,6 +57,25 @@ func TestService_Create(t *testing.T) { if len(updated.LinksTo) != 1 { t.Errorf("Expected 1 link, got %d", len(updated.LinksTo)) } + + // Verify that linked note only contains id and title + if link := updated.LinksTo[0]; link != nil { + if link.Content != "" { + t.Error("Link should not include content") + } + if link.ID == "" { + t.Error("Link should include ID") + } + if link.Title == "" { + t.Error("Link should include Title") + } + if !link.CreatedAt.IsZero() { + t.Error("Link should not include CreatedAt") + } + if !link.UpdatedAt.IsZero() { + t.Error("Link should not include UpdatedAt") + } + } } func TestService_Get(t *testing.T) { @@ -83,6 +102,18 @@ func TestService_Get(t *testing.T) { t.Fatalf("Failed to create note: %v", err) } + // Create another note that links to the first one + linking := &Note{ + Title: "Linking Note", + Content: "Links to [[Test Note]]", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := service.Create(linking); err != nil { + t.Fatalf("Failed to create linking note: %v", err) + } + + // Get the first note and verify relationships note, err = service.Get(created.ID) if err != nil { t.Fatalf("Failed to get note: %v", err) @@ -90,9 +121,35 @@ func TestService_Get(t *testing.T) { if note == nil { t.Fatal("Expected note, got nil") } + + // Verify basic fields if note.Title != created.Title { t.Errorf("Expected title %q, got %q", created.Title, note.Title) } + + // Verify LinkedBy relationship + if len(note.LinkedBy) != 1 { + t.Errorf("Expected 1 LinkedBy relationship, got %d", len(note.LinkedBy)) + } + + // Verify that linking note only contains id and title + if link := note.LinkedBy[0]; link != nil { + if link.Content != "" { + t.Error("LinkedBy should not include content") + } + if link.ID == "" { + t.Error("LinkedBy should include ID") + } + if link.Title == "" { + t.Error("LinkedBy should include Title") + } + if !link.CreatedAt.IsZero() { + t.Error("LinkedBy should not include CreatedAt") + } + if !link.UpdatedAt.IsZero() { + t.Error("LinkedBy should not include UpdatedAt") + } + } } func TestService_List(t *testing.T) { @@ -117,7 +174,7 @@ func TestService_List(t *testing.T) { } note2 := &Note{ Title: "Second Note", - Content: "Second content", + Content: "Second content with [[First Note]]", CreatedAt: time.Now(), UpdatedAt: time.Now().Add(time.Hour), // Later update time } @@ -142,6 +199,55 @@ func TestService_List(t *testing.T) { if notes[0].ID != note2.ID { t.Error("Notes not ordered by updated_at desc") } + + // Verify relationships + firstNote := notes[1] // note1 should be second due to update time + if len(firstNote.LinkedBy) != 1 { + t.Errorf("Expected First Note to have 1 LinkedBy, got %d", len(firstNote.LinkedBy)) + } + + // Verify that linking note only contains id and title + if link := firstNote.LinkedBy[0]; link != nil { + if link.Content != "" { + t.Error("LinkedBy should not include content") + } + if link.ID == "" { + t.Error("LinkedBy should include ID") + } + if link.Title == "" { + t.Error("LinkedBy should include Title") + } + if !link.CreatedAt.IsZero() { + t.Error("LinkedBy should not include CreatedAt") + } + if !link.UpdatedAt.IsZero() { + t.Error("LinkedBy should not include UpdatedAt") + } + } + + secondNote := notes[0] // note2 should be first due to update time + if len(secondNote.LinksTo) != 1 { + t.Errorf("Expected Second Note to have 1 LinksTo, got %d", len(secondNote.LinksTo)) + } + + // Verify that linked note only contains id and title + if link := secondNote.LinksTo[0]; link != nil { + if link.Content != "" { + t.Error("LinksTo should not include content") + } + if link.ID == "" { + t.Error("LinksTo should include ID") + } + if link.Title == "" { + t.Error("LinksTo should include Title") + } + if !link.CreatedAt.IsZero() { + t.Error("LinksTo should not include CreatedAt") + } + if !link.UpdatedAt.IsZero() { + t.Error("LinksTo should not include UpdatedAt") + } + } } func TestService_Delete(t *testing.T) { @@ -212,4 +318,4 @@ func TestService_Reset(t *testing.T) { if len(notes) != 0 { t.Errorf("Expected empty database after reset, got %d notes", len(notes)) } -} \ No newline at end of file +} diff --git a/build.sh b/scripts/build.sh similarity index 100% rename from build.sh rename to scripts/build.sh diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100755 index 0000000..f9ef219 --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -euo pipefail + +# Find commands and set up environment +if command -v bun >/dev/null 2>&1; then + BUN_CMD="bun" +else + BUN_CMD="$HOME/.bun/bin/bun" +fi + +# Set up Go environment +if command -v go >/dev/null 2>&1; then + GO_CMD="go" + GOROOT=$(go env GOROOT) +else + GO_CMD="/usr/local/go/bin/go" + GOROOT="/usr/local/go" +fi +export GOROOT +export PATH="$GOROOT/bin:$PATH" + +if command -v golangci-lint >/dev/null 2>&1; then + GOLINT_CMD="golangci-lint" +else + GOLINT_CMD="$HOME/go/bin/golangci-lint" +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +echo "Running pre-commit checks..." + +# Frontend checks +echo -e "\n${GREEN}Running frontend checks...${NC}" +pushd frontend + +echo -e "\n${GREEN}Running formatter...${NC}" +$BUN_CMD format || { + echo -e "${RED}Frontend formatting failed!${NC}" + exit 1 +} + +echo -e "\n${GREEN}Running linter...${NC}" +$BUN_CMD lint || { + echo -e "${RED}Frontend linting failed!${NC}" + exit 1 +} + +echo -e "\n${GREEN}Running type checker...${NC}" +$BUN_CMD check || { + echo -e "${RED}Frontend type checking failed!${NC}" + exit 1 +} + +echo -e "\n${GREEN}Building frontend...${NC}" +$BUN_CMD run build || { + echo -e "${RED}Frontend build failed!${NC}" + exit 1 +} + +# Backend checks +popd +echo -e "\n${GREEN}Running backend tests...${NC}" +$GO_CMD test -v ./... || { + echo -e "${RED}Backend tests failed!${NC}" + exit 1 +} + +echo -e "\n${GREEN}Running Go linter...${NC}" +$GOLINT_CMD run || { + echo -e "${RED}Go linting failed!${NC}" + exit 1 +} + +echo -e "\n${GREEN}All checks passed!${NC}" \ No newline at end of file