feat(go): implement note linking with tests
This commit is contained in:
parent
4837b26e58
commit
91e5d7529f
2 changed files with 331 additions and 4 deletions
151
main.go
151
main.go
|
@ -2,11 +2,13 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -77,6 +79,74 @@ type Note struct {
|
||||||
Content string `json:"content" gorm:"not null"`
|
Content string `json:"content" gorm:"not null"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link represents a connection between two notes
|
||||||
|
type NoteLink struct {
|
||||||
|
SourceNoteID string `gorm:"primaryKey"`
|
||||||
|
TargetNoteID string `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractLinks finds all [[note-title]] style links in the content
|
||||||
|
func (n *Note) ExtractLinks(content string) []string {
|
||||||
|
re := regexp.MustCompile(`\[\[(.*?)\]\]`)
|
||||||
|
matches := re.FindAllStringSubmatch(content, -1)
|
||||||
|
titles := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
titles = append(titles, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLinks updates the note's links based on its content
|
||||||
|
func (n *Note) UpdateLinks(db *gorm.DB) error {
|
||||||
|
// Start a transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to start transaction: %w", tx.Error)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Delete existing links
|
||||||
|
if err := tx.Where("source_note_id = ?", n.ID).Delete(&NoteLink{}).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to delete existing links: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and create new links
|
||||||
|
titles := n.ExtractLinks(n.Content)
|
||||||
|
for _, title := range titles {
|
||||||
|
var target Note
|
||||||
|
if err := tx.Where("title = ?", title).First(&target).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Skip non-existent notes
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to find target note %q: %w", title, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := NoteLink{
|
||||||
|
SourceNoteID: n.ID,
|
||||||
|
TargetNoteID: target.ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&link).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to create link to %q: %w", title, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
|
@ -120,7 +190,7 @@ func main() {
|
||||||
|
|
||||||
func handleGetNotes(c *gin.Context) {
|
func handleGetNotes(c *gin.Context) {
|
||||||
var notes []Note
|
var notes []Note
|
||||||
if err := db.Order("updated_at desc").Find(¬es).Error; err != nil {
|
if err := db.Preload("LinksTo").Order("updated_at desc").Find(¬es).Error; err != nil {
|
||||||
log.Printf("ERROR: Failed to query notes: %v", err)
|
log.Printf("ERROR: Failed to query notes: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
@ -140,12 +210,48 @@ func handleCreateNote(c *gin.Context) {
|
||||||
// Generate a new UUID for the note
|
// Generate a new UUID for the note
|
||||||
note.ID = uuid.New().String()
|
note.ID = uuid.New().String()
|
||||||
|
|
||||||
if err := db.Create(¬e).Error; err != nil {
|
// Start a transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
log.Printf("ERROR: Failed to start transaction: %v", tx.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": tx.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create the note
|
||||||
|
if err := tx.Create(¬e).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
log.Printf("ERROR: Failed to create note: %v", err)
|
log.Printf("ERROR: Failed to create note: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update links
|
||||||
|
if err := note.UpdateLinks(tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("ERROR: Failed to update note links: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
log.Printf("ERROR: Failed to commit transaction: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the note with its relationships
|
||||||
|
if err := db.Preload("LinksTo").First(¬e).Error; err != nil {
|
||||||
|
log.Printf("ERROR: Failed to load note relationships: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, note)
|
c.JSON(http.StatusCreated, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,7 +259,7 @@ func handleGetNote(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var note Note
|
var note Note
|
||||||
|
|
||||||
if err := db.First(¬e, "id = ?", id).Error; err != nil {
|
if err := db.Preload("LinksTo").Preload("LinkedBy").First(¬e, "id = ?", id).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
@ -176,16 +282,53 @@ func handleUpdateNote(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Model(&Note{}).Where("id = ?", id).Updates(map[string]interface{}{
|
// Start a transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
log.Printf("ERROR: Failed to start transaction: %v", tx.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": tx.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Update the note
|
||||||
|
if err := tx.Model(&Note{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"title": note.Title,
|
"title": note.Title,
|
||||||
"content": note.Content,
|
"content": note.Content,
|
||||||
"updated_at": note.UpdatedAt,
|
"updated_at": note.UpdatedAt,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
log.Printf("ERROR: Failed to update note %s: %v", id, err)
|
log.Printf("ERROR: Failed to update note %s: %v", id, err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the updated note for link processing
|
||||||
|
if err := tx.First(¬e, "id = ?", id).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("ERROR: Failed to load note %s: %v", id, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update links
|
||||||
|
if err := note.UpdateLinks(tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("ERROR: Failed to update note links: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
log.Printf("ERROR: Failed to commit transaction: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
184
main_test.go
Normal file
184
main_test.go
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) *gorm.DB {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&Note{}, &NoteLink{}); err != nil {
|
||||||
|
t.Fatalf("Failed to migrate test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestNote(t *testing.T, db *gorm.DB, title, content string) *Note {
|
||||||
|
note := &Note{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(note).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return note
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no links",
|
||||||
|
content: "Just some text without links",
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single link",
|
||||||
|
content: "Text with [[another note]] link",
|
||||||
|
expected: []string{"another note"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple links",
|
||||||
|
content: "Text with [[first]] and [[second]] links",
|
||||||
|
expected: []string{"first", "second"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repeated links",
|
||||||
|
content: "[[same]] link [[same]] twice",
|
||||||
|
expected: []string{"same", "same"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
note := &Note{}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := note.ExtractLinks(tt.content)
|
||||||
|
if len(got) != len(tt.expected) {
|
||||||
|
t.Errorf("ExtractLinks() got %v links, want %v", len(got), len(tt.expected))
|
||||||
|
}
|
||||||
|
for i, link := range got {
|
||||||
|
if link != tt.expected[i] {
|
||||||
|
t.Errorf("ExtractLinks() got %v, want %v", link, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLinks(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create some test notes
|
||||||
|
note1 := createTestNote(t, db, "First Note", "Content with [[Second Note]] and [[Third Note]]")
|
||||||
|
note2 := createTestNote(t, db, "Second Note", "Some content")
|
||||||
|
createTestNote(t, db, "Third Note", "More content") // Create but don't need to track
|
||||||
|
|
||||||
|
// Test creating links
|
||||||
|
t.Run("create links", func(t *testing.T) {
|
||||||
|
if err := note1.UpdateLinks(db); err != nil {
|
||||||
|
t.Fatalf("UpdateLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify links were created
|
||||||
|
var links []NoteLink
|
||||||
|
if err := db.Find(&links).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to query links: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(links) != 2 {
|
||||||
|
t.Errorf("Expected 2 links, got %d", len(links))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load note with relationships
|
||||||
|
var loadedNote Note
|
||||||
|
if err := db.Preload("LinksTo").First(&loadedNote, "id = ?", note1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to load note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loadedNote.LinksTo) != 2 {
|
||||||
|
t.Errorf("Expected 2 LinksTo relationships, got %d", len(loadedNote.LinksTo))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test updating links
|
||||||
|
t.Run("update links", func(t *testing.T) {
|
||||||
|
note1.Content = "Content with [[Second Note]] only" // Remove Third Note link
|
||||||
|
if err := note1.UpdateLinks(db); err != nil {
|
||||||
|
t.Fatalf("UpdateLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify links were updated
|
||||||
|
var links []NoteLink
|
||||||
|
if err := db.Find(&links).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to query links: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(links) != 1 {
|
||||||
|
t.Errorf("Expected 1 link, got %d", len(links))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load note with relationships
|
||||||
|
var loadedNote Note
|
||||||
|
if err := db.Preload("LinksTo").First(&loadedNote, "id = ?", note1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to load note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loadedNote.LinksTo) != 1 {
|
||||||
|
t.Errorf("Expected 1 LinksTo relationship, got %d", len(loadedNote.LinksTo))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test bidirectional relationships
|
||||||
|
t.Run("bidirectional relationships", func(t *testing.T) {
|
||||||
|
// Add a link back to First Note
|
||||||
|
note2.Content = "Content with [[First Note]]"
|
||||||
|
if err := note2.UpdateLinks(db); err != nil {
|
||||||
|
t.Fatalf("UpdateLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check First Note's LinkedBy
|
||||||
|
var note1Updated Note
|
||||||
|
if err := db.Preload("LinkedBy").First(¬e1Updated, "id = ?", note1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to load note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(note1Updated.LinkedBy) != 1 {
|
||||||
|
t.Errorf("Expected 1 LinkedBy relationship, got %d", len(note1Updated.LinkedBy))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test non-existent links
|
||||||
|
t.Run("non-existent links", func(t *testing.T) {
|
||||||
|
note1.Content = "Content with [[Non-existent Note]]"
|
||||||
|
if err := note1.UpdateLinks(db); err != nil {
|
||||||
|
t.Fatalf("UpdateLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no links were created
|
||||||
|
var links []NoteLink
|
||||||
|
if err := db.Find(&links).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to query links: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(links) != 1 { // Should still have the link from Second Note to First Note
|
||||||
|
t.Errorf("Expected 1 link, got %d", len(links))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue