Add thumbnail support to Canvas and storage backends

Introduces a 'thumbnail' field to the Canvas model and updates all storage backends (AWS S3, filesystem, memory, and SQLite) to handle storing and retrieving this field. Also updates the API handler to accept and save the thumbnail, and switches SQLite driver to modernc.org/sqlite for improved compatibility. Updates .gitignore to exclude .db files.
This commit is contained in:
Yuzhong Zhang
2025-07-06 15:02:58 +08:00
parent e4981703fe
commit 44414af085
9 changed files with 189 additions and 87 deletions
+54 -18
View File
@@ -3,15 +3,20 @@ package aws
import (
"bytes"
"context"
"encoding/json"
"errors"
"excalidraw-complete/core"
"fmt"
"io"
"io/ioutil"
"log"
"path"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/oklog/ulid/v2"
)
@@ -90,14 +95,30 @@ func (s *s3Store) List(ctx context.Context, userID string) ([]*core.Canvas, erro
canvases := make([]*core.Canvas, 0, len(output.Contents))
for _, object := range output.Contents {
canvasID := path.Base(*object.Key)
canvas := &core.Canvas{
ID: canvasID,
UserID: userID,
Name: canvasID, // S3 doesn't have a native 'name' field, using ID.
UpdatedAt: *object.LastModified,
resp, err := s.s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: object.Key,
})
if err != nil {
log.Printf("warn: failed to get object %s: %v", *object.Key, err)
continue
}
canvases = append(canvases, canvas)
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log.Printf("warn: failed to read object body %s: %v", *object.Key, err)
continue
}
var canvas core.Canvas
if err := json.Unmarshal(data, &canvas); err != nil {
log.Printf("warn: failed to unmarshal canvas %s: %v", *object.Key, err)
continue
}
// For list view, we don't need the full data blob.
canvas.Data = nil
canvases = append(canvases, &canvas)
}
return canvases, nil
@@ -111,35 +132,50 @@ func (s *s3Store) Get(ctx context.Context, userID, id string) (*core.Canvas, err
})
if err != nil {
// A specific check for NoSuchKey can be useful here.
if bytes.Contains([]byte(err.Error()), []byte("NoSuchKey")) {
var nsk *s3types.NoSuchKey
if errors.As(err, &nsk) {
return nil, fmt.Errorf("canvas not found")
}
return nil, fmt.Errorf("failed to get canvas %s: %v", id, err)
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read canvas data: %v", err)
}
canvas := &core.Canvas{
ID: id,
UserID: userID,
Name: id,
Data: data,
UpdatedAt: *resp.LastModified,
var canvas core.Canvas
if err := json.Unmarshal(data, &canvas); err != nil {
return nil, fmt.Errorf("failed to unmarshal canvas data: %v", err)
}
return canvas, nil
return &canvas, nil
}
func (s *s3Store) Save(ctx context.Context, canvas *core.Canvas) error {
key := s.getCanvasKey(canvas.UserID, canvas.ID)
_, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{
// Preserve CreatedAt on update
if canvas.CreatedAt.IsZero() {
existing, err := s.Get(ctx, canvas.UserID, canvas.ID)
if err == nil && existing != nil {
canvas.CreatedAt = existing.CreatedAt
} else {
canvas.CreatedAt = time.Now()
}
}
canvas.UpdatedAt = time.Now()
data, err := json.Marshal(canvas)
if err != nil {
return fmt.Errorf("failed to marshal canvas: %v", err)
}
_, err = s.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(canvas.Data),
Body: bytes.NewReader(data),
})
if err != nil {
return fmt.Errorf("failed to save canvas %s: %v", canvas.ID, err)
+35 -24
View File
@@ -3,6 +3,7 @@ package filesystem
import (
"bytes"
"context"
"encoding/json"
"excalidraw-complete/core"
"fmt"
"log"
@@ -90,18 +91,22 @@ func (s *fsStore) List(ctx context.Context, userID string) ([]*core.Canvas, erro
canvases := make([]*core.Canvas, 0, len(files))
for _, file := range files {
if !file.IsDir() {
info, err := file.Info()
filePath := filepath.Join(userPath, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
log.WithError(err).Warn("Failed to get file info, skipping file")
log.WithError(err).Warnf("Failed to read canvas file %s, skipping", file.Name())
continue
}
canvas := &core.Canvas{
ID: file.Name(),
UserID: userID,
Name: file.Name(),
UpdatedAt: info.ModTime(),
var canvas core.Canvas
if err := json.Unmarshal(data, &canvas); err != nil {
log.WithError(err).Warnf("Failed to unmarshal canvas file %s, skipping", file.Name())
continue
}
canvases = append(canvases, canvas)
// For list view, we don't need the full data blob.
canvas.Data = nil
canvases = append(canvases, &canvas)
}
}
@@ -130,16 +135,15 @@ func (s *fsStore) Get(ctx context.Context, userID, id string) (*core.Canvas, err
return nil, err
}
canvas := &core.Canvas{
ID: id,
UserID: userID,
Name: id,
Data: data,
UpdatedAt: info.ModTime(),
var canvas core.Canvas
if err := json.Unmarshal(data, &canvas); err != nil {
log.WithError(err).Error("Failed to unmarshal canvas data")
return nil, err
}
canvas.UpdatedAt = info.ModTime()
log.Info("Canvas retrieved successfully")
return canvas, nil
return &canvas, nil
}
func (s *fsStore) Save(ctx context.Context, canvas *core.Canvas) error {
@@ -152,21 +156,28 @@ func (s *fsStore) Save(ctx context.Context, canvas *core.Canvas) error {
return err
}
// Set creation/update time before saving
info, err := os.Stat(filePath)
if os.IsNotExist(err) {
canvas.CreatedAt = time.Now()
} else if err == nil {
canvas.CreatedAt = info.ModTime() // This is not ideal, but filesystem doesn't store creation time easily.
}
canvas.UpdatedAt = time.Now()
log.Info("Saving canvas")
err := os.WriteFile(filePath, canvas.Data, 0644)
data, err := json.Marshal(canvas)
if err != nil {
log.WithError(err).Error("Failed to marshal canvas for saving")
return err
}
err = os.WriteFile(filePath, data, 0644)
if err != nil {
log.WithError(err).Error("Failed to write canvas file")
return err
}
// Set modification time for consistency, though WriteFile usually does this.
// We preserve created time logic in the storage layer if needed.
now := time.Now()
canvas.UpdatedAt = now
// A full implementation would handle CreatedAt by checking if the file exists first.
// For this KV-like store, we'll just update ModTime via WriteFile.
return nil
}
+1
View File
@@ -74,6 +74,7 @@ func (s *memStore) List(ctx context.Context, userID string) ([]*core.Canvas, err
ID: canvas.ID,
UserID: canvas.UserID,
Name: canvas.Name,
Thumbnail: canvas.Thumbnail,
CreatedAt: canvas.CreatedAt,
UpdatedAt: canvas.UpdatedAt,
}
+8 -7
View File
@@ -9,9 +9,9 @@ import (
"log"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/oklog/ulid/v2"
"github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
)
type sqliteStore struct {
@@ -20,7 +20,7 @@ type sqliteStore struct {
// NewStore creates a new SQLite-based store.
func NewStore(dataSourceName string) *sqliteStore {
db, err := sql.Open("sqlite3", dataSourceName)
db, err := sql.Open("sqlite", dataSourceName)
if err != nil {
log.Fatalf("failed to open sqlite database: %v", err)
}
@@ -37,6 +37,7 @@ func NewStore(dataSourceName string) *sqliteStore {
id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT,
thumbnail TEXT,
data BLOB,
created_at DATETIME,
updated_at DATETIME,
@@ -89,7 +90,7 @@ func (s *sqliteStore) Create(ctx context.Context, document *core.Document) (stri
// CanvasStore implementation
func (s *sqliteStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, updated_at FROM canvases WHERE user_id = ?", userID)
rows, err := s.db.QueryContext(ctx, "SELECT id, name, updated_at, thumbnail FROM canvases WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
@@ -99,7 +100,7 @@ func (s *sqliteStore) List(ctx context.Context, userID string) ([]*core.Canvas,
for rows.Next() {
var canvas core.Canvas
canvas.UserID = userID
if err := rows.Scan(&canvas.ID, &canvas.Name, &canvas.UpdatedAt); err != nil {
if err := rows.Scan(&canvas.ID, &canvas.Name, &canvas.UpdatedAt, &canvas.Thumbnail); err != nil {
return nil, err
}
canvases = append(canvases, &canvas)
@@ -111,7 +112,7 @@ func (s *sqliteStore) Get(ctx context.Context, userID, id string) (*core.Canvas,
var canvas core.Canvas
canvas.UserID = userID
canvas.ID = id
err := s.db.QueryRowContext(ctx, "SELECT name, data, created_at, updated_at FROM canvases WHERE user_id = ? AND id = ?", userID, id).Scan(&canvas.Name, &canvas.Data, &canvas.CreatedAt, &canvas.UpdatedAt)
err := s.db.QueryRowContext(ctx, "SELECT name, data, created_at, updated_at, thumbnail FROM canvases WHERE user_id = ? AND id = ?", userID, id).Scan(&canvas.Name, &canvas.Data, &canvas.CreatedAt, &canvas.UpdatedAt, &canvas.Thumbnail)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("canvas not found")
@@ -138,10 +139,10 @@ func (s *sqliteStore) Save(ctx context.Context, canvas *core.Canvas) error {
if exists {
// Update
_, err = tx.ExecContext(ctx, "UPDATE canvases SET name = ?, data = ?, updated_at = ? WHERE user_id = ? AND id = ?", canvas.Name, canvas.Data, now, canvas.UserID, canvas.ID)
_, err = tx.ExecContext(ctx, "UPDATE canvases SET name = ?, data = ?, updated_at = ?, thumbnail = ? WHERE user_id = ? AND id = ?", canvas.Name, canvas.Data, now, canvas.Thumbnail, canvas.UserID, canvas.ID)
} else {
// Insert
_, err = tx.ExecContext(ctx, "INSERT INTO canvases (id, user_id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", canvas.ID, canvas.UserID, canvas.Name, canvas.Data, now, now)
_, err = tx.ExecContext(ctx, "INSERT INTO canvases (id, user_id, name, data, created_at, updated_at, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)", canvas.ID, canvas.UserID, canvas.Name, canvas.Data, now, now, canvas.Thumbnail)
}
if err != nil {