Implement annotations and changes
This commit is contained in:
@@ -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
110
src/handlers/annotations.go
Normal 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
110
src/handlers/changes.go
Normal 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
13
src/handlers/health.go
Normal 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
16
src/models/annotation.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user