Compare commits
5 Commits
b2bcbb3301
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a925e4ab78 | |||
| b75697a39f | |||
| 4440cb832f | |||
| 430e29d51b | |||
| 3ae2eacb6b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ sheetless-server
|
||||
sheetless.db
|
||||
/result
|
||||
/.env
|
||||
/src/vendor/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
132
src/handlers/annotations.go
Normal file
132
src/handlers/annotations.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// parseFlexibleTime parses time strings in various ISO8601/RFC3339 formats
|
||||
func parseFlexibleTime(s string) (time.Time, error) {
|
||||
// Try multiple formats that Dart/Flutter might send
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05.999999999", // Dart ISO8601 without timezone
|
||||
"2006-01-02T15:04:05.999999", // Dart ISO8601 with microseconds
|
||||
"2006-01-02T15:04:05.999", // Dart ISO8601 with milliseconds
|
||||
"2006-01-02T15:04:05", // Dart ISO8601 without fractional seconds
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse time: %s", s)
|
||||
}
|
||||
|
||||
// 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 (flexible format to support Dart/Flutter)
|
||||
lastModified, err := parseFlexibleTime(req.LastModified)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid lastModified format: " + err.Error()})
|
||||
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})
|
||||
}
|
||||
109
src/handlers/changes.go
Normal file
109
src/handlers/changes.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sheetless-server/database"
|
||||
"sheetless-server/models"
|
||||
|
||||
"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 (flexible format to support Dart/Flutter)
|
||||
createdAt, err := parseFlexibleTime(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"})
|
||||
}
|
||||
@@ -25,24 +25,8 @@ func UploadSheet(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
composerUUIDStr := c.PostForm("composer_uuid")
|
||||
if composerUUIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Composer UUID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
composerUUID, err := uuid.Parse(composerUUIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid composer UUID"})
|
||||
return
|
||||
}
|
||||
|
||||
var composer models.Composer
|
||||
if err := database.DB.First(&composer, composerUUID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Composer not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: create new composer with this name if it does not exist here
|
||||
composer := c.PostForm("composer")
|
||||
description := c.PostForm("description")
|
||||
|
||||
// Get uploaded file
|
||||
@@ -104,7 +88,7 @@ func UploadSheet(c *gin.Context) {
|
||||
FilePath: filePath,
|
||||
FileSize: fileInfo.Size(),
|
||||
FileHash: fileHash,
|
||||
ComposerUuid: composer.Uuid,
|
||||
Composer: composer,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -125,7 +109,7 @@ func UploadSheet(c *gin.Context) {
|
||||
func ListSheets(c *gin.Context) {
|
||||
var sheets []models.Sheet
|
||||
|
||||
if err := database.DB.Preload("Composer").Find(&sheets).Error; err != nil {
|
||||
if err := database.DB.Find(&sheets).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch sheets"})
|
||||
return
|
||||
}
|
||||
@@ -157,7 +141,7 @@ func DownloadSheet(c *gin.Context) {
|
||||
}
|
||||
|
||||
func GenerateNonexistentSheetUuid() (*uuid.UUID, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
uuid := uuid.New()
|
||||
|
||||
var exists bool
|
||||
|
||||
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
|
||||
}
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
type Sheet struct {
|
||||
Uuid uuid.UUID `json:"uuid" gorm:"type:uuid;primaryKey"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
FilePath string `json:"file_path" gorm:"not null"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileHash string `json:"file_hash"`
|
||||
ComposerUuid uuid.UUID `json:"composer_uuid"`
|
||||
Composer Composer `json:"composer" gorm:"foreignKey:ComposerUuid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Description string `json:"-"`
|
||||
FilePath string `json:"-" gorm:"not null"`
|
||||
FileSize int64 `json:"-"`
|
||||
FileHash string `json:"-"`
|
||||
Composer string `json:"composer"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Annotations string `json:"-"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user