Compare commits
5 Commits
b2bcbb3301
...
a925e4ab78
| Author | SHA1 | Date | |
|---|---|---|---|
| a925e4ab78 | |||
| b75697a39f | |||
| 4440cb832f | |||
| 430e29d51b | |||
| 3ae2eacb6b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ sheetless-server
|
|||||||
sheetless.db
|
sheetless.db
|
||||||
/result
|
/result
|
||||||
/.env
|
/.env
|
||||||
|
/src/vendor/
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
composerUUIDStr := c.PostForm("composer_uuid")
|
// TODO: create new composer with this name if it does not exist here
|
||||||
if composerUUIDStr == "" {
|
composer := c.PostForm("composer")
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
description := c.PostForm("description")
|
description := c.PostForm("description")
|
||||||
|
|
||||||
// Get uploaded file
|
// Get uploaded file
|
||||||
@@ -104,7 +88,7 @@ func UploadSheet(c *gin.Context) {
|
|||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
FileSize: fileInfo.Size(),
|
FileSize: fileInfo.Size(),
|
||||||
FileHash: fileHash,
|
FileHash: fileHash,
|
||||||
ComposerUuid: composer.Uuid,
|
Composer: composer,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -125,7 +109,7 @@ func UploadSheet(c *gin.Context) {
|
|||||||
func ListSheets(c *gin.Context) {
|
func ListSheets(c *gin.Context) {
|
||||||
var sheets []models.Sheet
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch sheets"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -157,7 +141,7 @@ func DownloadSheet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GenerateNonexistentSheetUuid() (*uuid.UUID, error) {
|
func GenerateNonexistentSheetUuid() (*uuid.UUID, error) {
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
uuid := uuid.New()
|
uuid := uuid.New()
|
||||||
|
|
||||||
var exists bool
|
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 {
|
type Sheet struct {
|
||||||
Uuid uuid.UUID `json:"uuid" gorm:"type:uuid;primaryKey"`
|
Uuid uuid.UUID `json:"uuid" gorm:"type:uuid;primaryKey"`
|
||||||
Title string `json:"title" gorm:"not null"`
|
Title string `json:"title" gorm:"not null"`
|
||||||
Description string `json:"description"`
|
Description string `json:"-"`
|
||||||
FilePath string `json:"file_path" gorm:"not null"`
|
FilePath string `json:"-" gorm:"not null"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"-"`
|
||||||
FileHash string `json:"file_hash"`
|
FileHash string `json:"-"`
|
||||||
ComposerUuid uuid.UUID `json:"composer_uuid"`
|
Composer string `json:"composer"`
|
||||||
Composer Composer `json:"composer" gorm:"foreignKey:ComposerUuid"`
|
CreatedAt time.Time `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
Annotations string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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