From b75697a39f89407fdda0c45f7ad8cbe5cbdc7193 Mon Sep 17 00:00:00 2001 From: Julian Mutter Date: Fri, 6 Feb 2026 17:05:35 +0100 Subject: [PATCH] Implement annotations and changes --- src/database/connection.go | 2 +- src/handlers/annotations.go | 110 ++++++++++++++++++++++++++++++++++++ src/handlers/changes.go | 110 ++++++++++++++++++++++++++++++++++++ src/handlers/health.go | 13 +++++ src/models/annotation.go | 16 ++++++ src/routes/routes.go | 15 +++++ 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/handlers/annotations.go create mode 100644 src/handlers/changes.go create mode 100644 src/handlers/health.go create mode 100644 src/models/annotation.go diff --git a/src/database/connection.go b/src/database/connection.go index cea7d64..99dc508 100644 --- a/src/database/connection.go +++ b/src/database/connection.go @@ -29,7 +29,7 @@ func InitDatabase() { isNewDatabase := len(tables) == 0 // Auto migrate the schema - err = DB.AutoMigrate(&models.User{}, &models.Sheet{}, &models.Composer{}) + err = DB.AutoMigrate(&models.User{}, &models.Sheet{}, &models.Composer{}, &models.Annotation{}) if err != nil { log.Fatal("Failed to migrate database:", err) } diff --git a/src/handlers/annotations.go b/src/handlers/annotations.go new file mode 100644 index 0000000..b1570ce --- /dev/null +++ b/src/handlers/annotations.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "net/http" + "sheetless-server/database" + "sheetless-server/models" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// AnnotationRequest is the request body for creating/updating an annotation +type AnnotationRequest struct { + Page int `json:"page" binding:"required"` + LastModified string `json:"lastModified" binding:"required"` + Annotations string `json:"annotations" binding:"required"` +} + +// GetAnnotations returns all annotations for a sheet +// GET /api/sheets/:uuid/annotations +func GetAnnotations(c *gin.Context) { + sheetUuid, err := uuid.Parse(c.Param("uuid")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid sheet uuid"}) + return + } + + // Verify sheet exists + var sheet models.Sheet + if err := database.DB.First(&sheet, sheetUuid).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Sheet not found"}) + return + } + + // Get all annotations for this sheet + var annotations []models.Annotation + if err := database.DB.Where("sheet_uuid = ?", sheetUuid).Find(&annotations).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch annotations"}) + return + } + + c.JSON(http.StatusOK, annotations) +} + +// UpdateAnnotation creates or updates an annotation for a specific page +// POST /api/sheets/:uuid/annotations +func UpdateAnnotation(c *gin.Context) { + sheetUuid, err := uuid.Parse(c.Param("uuid")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid sheet uuid"}) + return + } + + // Verify sheet exists + var sheet models.Sheet + if err := database.DB.First(&sheet, sheetUuid).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Sheet not found"}) + return + } + + var req AnnotationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: " + err.Error()}) + return + } + + // Parse lastModified timestamp + lastModified, err := time.Parse(time.RFC3339, req.LastModified) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid lastModified format, expected RFC3339"}) + return + } + + // Check if annotation already exists + var existing models.Annotation + result := database.DB.Where("sheet_uuid = ? AND page = ?", sheetUuid, req.Page).First(&existing) + + if result.Error == nil { + // Annotation exists - only update if incoming is newer + if lastModified.After(existing.LastModified) { + existing.LastModified = lastModified + existing.Annotations = req.Annotations + if err := database.DB.Save(&existing).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update annotation"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Annotation updated", "annotation": existing}) + } else { + // Server has newer or same version, ignore + c.JSON(http.StatusOK, gin.H{"message": "Server has newer version", "annotation": existing}) + } + return + } + + // Create new annotation + annotation := models.Annotation{ + SheetUuid: sheetUuid, + Page: req.Page, + LastModified: lastModified, + Annotations: req.Annotations, + } + + if err := database.DB.Create(&annotation).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create annotation"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Annotation created", "annotation": annotation}) +} diff --git a/src/handlers/changes.go b/src/handlers/changes.go new file mode 100644 index 0000000..f62ccef --- /dev/null +++ b/src/handlers/changes.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "net/http" + "sheetless-server/database" + "sheetless-server/models" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ChangeType represents the type of change being synced +type ChangeType string + +const ( + ChangeTypeSheetName ChangeType = "sheetNameChange" + ChangeTypeComposerName ChangeType = "composerNameChange" + ChangeTypeAddTag ChangeType = "addTagChange" + ChangeTypeRemoveTag ChangeType = "removeTagChange" +) + +// Change represents a single change from the client +type Change struct { + Type ChangeType `json:"type" binding:"required"` + SheetUuid string `json:"sheetUuid" binding:"required"` + Value string `json:"value" binding:"required"` + CreatedAt string `json:"createdAt" binding:"required"` +} + +// SyncChangesRequest is the request body for syncing changes +type SyncChangesRequest struct { + Changes []Change `json:"changes" binding:"required"` +} + +// SyncChangesResponse is the response for the sync endpoint +type SyncChangesResponse struct { + Applied []int `json:"applied"` // Indices of changes that were applied +} + +// SyncChanges processes a batch of changes from the client +// POST /api/changes/sync +// +// Changes are applied based on their createdAt timestamp - only the newest +// change for each field is kept when resolving conflicts between devices. +func SyncChanges(c *gin.Context) { + var req SyncChangesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: " + err.Error()}) + return + } + + applied := make([]int, 0) + + for i, change := range req.Changes { + // Parse the sheet UUID + sheetUuid, err := uuid.Parse(change.SheetUuid) + if err != nil { + continue // Skip invalid UUIDs + } + + // Parse createdAt timestamp + createdAt, err := time.Parse(time.RFC3339, change.CreatedAt) + if err != nil { + continue // Skip invalid timestamps + } + + // Find the sheet + var sheet models.Sheet + if err := database.DB.First(&sheet, sheetUuid).Error; err != nil { + continue // Skip if sheet not found + } + + // Apply the change based on type + changed := false + switch change.Type { + case ChangeTypeSheetName: + // Only apply if this change is newer than the sheet's last update + // for this field (we use UpdatedAt as a simple proxy) + if createdAt.After(sheet.UpdatedAt) || sheet.Title != change.Value { + sheet.Title = change.Value + sheet.UpdatedAt = createdAt + changed = true + } + + case ChangeTypeComposerName: + if createdAt.After(sheet.UpdatedAt) || sheet.Composer != change.Value { + sheet.Composer = change.Value + sheet.UpdatedAt = createdAt + changed = true + } + + case ChangeTypeAddTag, ChangeTypeRemoveTag: + // Tags not implemented yet + continue + } + + if changed { + if err := database.DB.Save(&sheet).Error; err != nil { + continue // Skip if save fails + } + applied = append(applied, i) + } else { + // Even if not changed (same value), consider it applied + applied = append(applied, i) + } + } + + c.JSON(http.StatusOK, SyncChangesResponse{Applied: applied}) +} diff --git a/src/handlers/health.go b/src/handlers/health.go new file mode 100644 index 0000000..23b5063 --- /dev/null +++ b/src/handlers/health.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// HealthCheck returns a simple OK response for connection checking +// GET /api/health +func HealthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/src/models/annotation.go b/src/models/annotation.go new file mode 100644 index 0000000..dcac2a0 --- /dev/null +++ b/src/models/annotation.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Annotation represents drawing annotations on a specific page of a sheet. +// The primary key is composite: (SheetUuid, Page). +type Annotation struct { + SheetUuid uuid.UUID `json:"sheetUuid" gorm:"type:uuid;primaryKey"` + Page int `json:"page" gorm:"primaryKey"` + LastModified time.Time `json:"lastModified" gorm:"not null"` + Annotations string `json:"annotations" gorm:"type:text"` // JSON string of drawing data +} diff --git a/src/routes/routes.go b/src/routes/routes.go index ddb7462..6b107ce 100644 --- a/src/routes/routes.go +++ b/src/routes/routes.go @@ -19,18 +19,33 @@ func SetupRoutes(r *gin.Engine) { api := r.Group("/api") api.Use(middleware.AuthMiddleware()) { + // Health check for connection testing + api.GET("/health", handlers.HealthCheck) + + // Sheets endpoints sheets := api.Group("/sheets") { sheets.POST("/upload", handlers.UploadSheet) sheets.GET("/list", handlers.ListSheets) sheets.GET("/get/:uuid", handlers.DownloadSheet) + + // Annotations endpoints (nested under sheets) + sheets.GET("/:uuid/annotations", handlers.GetAnnotations) + sheets.POST("/:uuid/annotations", handlers.UpdateAnnotation) } + // Composers endpoints composers := api.Group("/composers") { composers.POST("/add", handlers.AddComposer) composers.GET("/list", handlers.ListComposers) composers.GET("/get/:uuid", handlers.GetComposer) } + + // Changes sync endpoint + changes := api.Group("/changes") + { + changes.POST("/sync", handlers.SyncChanges) + } } }