Implement annotations and changes

This commit is contained in:
2026-02-06 17:05:35 +01:00
parent 4440cb832f
commit b75697a39f
6 changed files with 265 additions and 1 deletions

View File

@@ -29,7 +29,7 @@ func InitDatabase() {
isNewDatabase := len(tables) == 0 isNewDatabase := len(tables) == 0
// Auto migrate the schema // 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 { if err != nil {
log.Fatal("Failed to migrate database:", err) log.Fatal("Failed to migrate database:", err)
} }

110
src/handlers/annotations.go Normal file
View File

@@ -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})
}

110
src/handlers/changes.go Normal file
View File

@@ -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})
}

13
src/handlers/health.go Normal file
View File

@@ -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"})
}

16
src/models/annotation.go Normal file
View File

@@ -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
}

View File

@@ -19,18 +19,33 @@ func SetupRoutes(r *gin.Engine) {
api := r.Group("/api") api := r.Group("/api")
api.Use(middleware.AuthMiddleware()) api.Use(middleware.AuthMiddleware())
{ {
// Health check for connection testing
api.GET("/health", handlers.HealthCheck)
// Sheets endpoints
sheets := api.Group("/sheets") sheets := api.Group("/sheets")
{ {
sheets.POST("/upload", handlers.UploadSheet) sheets.POST("/upload", handlers.UploadSheet)
sheets.GET("/list", handlers.ListSheets) sheets.GET("/list", handlers.ListSheets)
sheets.GET("/get/:uuid", handlers.DownloadSheet) 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 := api.Group("/composers")
{ {
composers.POST("/add", handlers.AddComposer) composers.POST("/add", handlers.AddComposer)
composers.GET("/list", handlers.ListComposers) composers.GET("/list", handlers.ListComposers)
composers.GET("/get/:uuid", handlers.GetComposer) composers.GET("/get/:uuid", handlers.GetComposer)
} }
// Changes sync endpoint
changes := api.Group("/changes")
{
changes.POST("/sync", handlers.SyncChanges)
}
} }
} }