* api: detect an absent copy body by io.EOF from the decoder rather than
Content-Length. A chunked request reports -1, not 0, so the length test
read an empty chunked body as malformed and 400'd instead of taking the
default-name path. Covered by a new unknown-length test.
* store: hoist the garden_objects/plantings INSERTs into shared
objectInsert/plantingInsert statements with matching *InsertArgs
helpers. CopyGarden had its own copies of both column lists, so a
column added to the table could be wired into Create* and silently
dropped from a copy.
* store: move the queryer interface to sqlite.go, next to the other
shared query plumbing, instead of users.go.
* web: derive a copy's prefilled name via defaultCopyName, mirroring the
server's copyName including its 200-BYTE cap. The inline
`${name} (copy)` both duplicated the suffix and, for a garden whose
name was already at the cap, prefilled an over-long name that the
server rejected with a 400. Unit-tested, including multi-byte
truncation on a code-point boundary.
* test: drop a throwaway `_ = kept`.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -115,14 +116,14 @@ func (h *handlers) copyGarden(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The body is optional — POST with no body means "copy under the default
|
// The body is optional — POST with no body means "copy under the default
|
||||||
// name" — so only decode when one was actually sent.
|
// name". An absent body surfaces as io.EOF from the decoder, which is the
|
||||||
|
// reliable signal: Content-Length is -1 (not 0) for a chunked request, so
|
||||||
|
// testing the length would misread an empty chunked body as malformed.
|
||||||
var req gardenCopyRequest
|
var req gardenCopyRequest
|
||||||
if c.Request.ContentLength != 0 {
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "name must be a string")
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "name must be a string")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
g, err := h.svc.CopyGarden(c.Request.Context(), mustActor(c).ID, id, req.Name)
|
g, err := h.svc.CopyGarden(c.Request.Context(), mustActor(c).ID, id, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(c, err)
|
writeServiceError(c, err)
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -247,3 +250,32 @@ func TestCopyGardenEndpoint(t *testing.T) {
|
|||||||
t.Errorf("bad body status = %d, want 400", w.Code)
|
t.Errorf("bad body status = %d, want 400", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A body of unknown length (chunked transfer, Content-Length -1) that turns out
|
||||||
|
// to be empty must take the default-name path, not fail as malformed.
|
||||||
|
func TestCopyGardenEmptyChunkedBody(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/gardens", map[string]any{"name": "Home"}, cookie)
|
||||||
|
id := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
// A plain io.Reader (not a *strings.Reader/*bytes.Buffer) leaves
|
||||||
|
// ContentLength at -1, which is what a chunked request looks like server-side.
|
||||||
|
req := httptest.NewRequest(http.MethodPost,
|
||||||
|
"/api/v1/gardens/"+strconv.FormatInt(id, 10)+"/copy", io.NopCloser(strings.NewReader("")))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
if req.ContentLength != -1 {
|
||||||
|
t.Fatalf("test setup: ContentLength = %d, want -1 (unknown)", req.ContentLength)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := decodeMap(t, rec.Body.Bytes())["name"]; got != "Home (copy)" {
|
||||||
|
t.Errorf("name = %v, want the derived 'Home (copy)'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -345,9 +345,8 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
|
|||||||
bed := seedBed(t, s, owner, src.ID)
|
bed := seedBed(t, s, owner, src.ID)
|
||||||
plant := seedOwnPlant(t, s, owner, 20)
|
plant := seedOwnPlant(t, s, owner, 20)
|
||||||
|
|
||||||
kept, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, RadiusCM: 10})
|
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, RadiusCM: 10}); err != nil {
|
||||||
if err != nil {
|
t.Fatalf("CreatePlanting(first): %v", err)
|
||||||
t.Fatalf("CreatePlanting(kept): %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 50, RadiusCM: 10}); err != nil {
|
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 50, RadiusCM: 10}); err != nil {
|
||||||
t.Fatalf("CreatePlanting(cleared): %v", err)
|
t.Fatalf("CreatePlanting(cleared): %v", err)
|
||||||
@@ -361,7 +360,6 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreatePlanting(replanted): %v", err)
|
t.Fatalf("CreatePlanting(replanted): %v", err)
|
||||||
}
|
}
|
||||||
_ = kept
|
|
||||||
|
|
||||||
dup, err := s.CopyGarden(ctx, owner, src.ID, "")
|
dup, err := s.CopyGarden(ctx, owner, src.ID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -213,16 +213,7 @@ func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string)
|
|||||||
// re-parented onto the right new object.
|
// re-parented onto the right new object.
|
||||||
objectIDs := make(map[int64]int64, len(srcObjects))
|
objectIDs := make(map[int64]int64, len(srcObjects))
|
||||||
for _, o := range srcObjects {
|
for _, o := range srcObjects {
|
||||||
copied, err := scanObject(tx.QueryRowContext(ctx,
|
copied, err := scanObject(tx.QueryRowContext(ctx, objectInsert, objectInsertArgs(created.ID, &o)...))
|
||||||
`INSERT INTO garden_objects
|
|
||||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
|
||||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING `+objectColumns,
|
|
||||||
created.ID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
|
||||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
|
||||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
|
||||||
))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: copy object: %w", err)
|
return nil, fmt.Errorf("store: copy object: %w", err)
|
||||||
}
|
}
|
||||||
@@ -243,11 +234,7 @@ func (d *DB) CopyGarden(ctx context.Context, srcID, ownerID int64, name string)
|
|||||||
// Unreachable: every active planting joins an object we just copied.
|
// Unreachable: every active planting joins an object we just copied.
|
||||||
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
|
return nil, fmt.Errorf("store: copy planting %d: source object %d not copied", p.ID, p.ObjectID)
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(objectID, &p)...)); err != nil {
|
||||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: copy planting: %w", err)
|
return nil, fmt.Errorf("store: copy planting: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,30 @@ func scanObject(s scanner) (*domain.GardenObject, error) {
|
|||||||
return &o, nil
|
return &o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateObject inserts a garden object (fields already validated by the service)
|
// objectInsert inserts one garden_objects row and returns it. It is shared by
|
||||||
// and returns the stored row.
|
// CreateObject and CopyGarden's in-transaction copy, with objectInsertArgs
|
||||||
func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
// supplying its parameters, so a column added to the table can't be wired into
|
||||||
created, err := scanObject(d.sql.QueryRowContext(ctx,
|
// one path and silently dropped from the other.
|
||||||
`INSERT INTO garden_objects
|
const objectInsert = `INSERT INTO garden_objects
|
||||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING `+objectColumns,
|
RETURNING ` + objectColumns
|
||||||
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
|
||||||
|
// objectInsertArgs builds objectInsert's parameters, placing o in gardenID (which
|
||||||
|
// is o's own garden on create, and the new garden when copying).
|
||||||
|
func objectInsertArgs(gardenID int64, o *domain.GardenObject) []any {
|
||||||
|
return []any{
|
||||||
|
gardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||||
))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateObject inserts a garden object (fields already validated by the service)
|
||||||
|
// and returns the stored row.
|
||||||
|
func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
||||||
|
created, err := scanObject(d.sql.QueryRowContext(ctx, objectInsert, objectInsertArgs(o.GardenID, o)...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: insert object: %w", err)
|
return nil, fmt.Errorf("store: insert object: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-14
@@ -104,16 +104,25 @@ func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePlanting inserts a plop (fields already validated by the service) and
|
// plantingInsert inserts one plantings row and returns it. Shared by
|
||||||
// returns the stored row. removed_at is always NULL on create — a new plop is
|
// CreatePlanting, the CreatePlantings batch and CopyGarden's in-transaction copy
|
||||||
// active; "clear bed" sets removed_at later via UpdatePlanting.
|
// — with plantingInsertArgs supplying its parameters — so all three stay in step
|
||||||
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
// with the table. removed_at is never set here: a new plop is active, and "clear
|
||||||
created, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
// bed" sets removed_at later via UpdatePlanting.
|
||||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING `+plantingColumns,
|
RETURNING ` + plantingColumns
|
||||||
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt,
|
|
||||||
))
|
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
|
||||||
|
// (p's own object normally, and the copied object when copying a garden).
|
||||||
|
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
|
||||||
|
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePlanting inserts a plop (fields already validated by the service) and
|
||||||
|
// returns the stored row.
|
||||||
|
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
||||||
|
created, err := scanPlanting(d.sql.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: insert planting: %w", err)
|
return nil, fmt.Errorf("store: insert planting: %w", err)
|
||||||
}
|
}
|
||||||
@@ -132,13 +141,9 @@ func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting)
|
|||||||
}
|
}
|
||||||
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
||||||
|
|
||||||
const stmt = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING ` + plantingColumns
|
|
||||||
out := make([]domain.Planting, 0, len(plantings))
|
out := make([]domain.Planting, 0, len(plantings))
|
||||||
for _, p := range plantings {
|
for _, p := range plantings {
|
||||||
created, err := scanPlanting(tx.QueryRowContext(ctx, stmt,
|
created, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
||||||
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
|
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -99,6 +100,13 @@ func pragmaName(p string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(p))
|
return strings.ToLower(strings.TrimSpace(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryer is the read surface shared by *sql.DB and *sql.Tx, so a list helper can
|
||||||
|
// serve both a standalone read and one inside a transaction. (Its single-row
|
||||||
|
// counterpart is `scanner`, in users.go.)
|
||||||
|
type queryer interface {
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
// qualifyColumns prefixes each comma-separated column in cols with "alias." so a
|
// qualifyColumns prefixes each comma-separated column in cols with "alias." so a
|
||||||
// shared column list can be used in a JOIN — e.g. qualifyColumns("pl", "id, x")
|
// shared column list can be used in a JOIN — e.g. qualifyColumns("pl", "id, x")
|
||||||
// → "pl.id, pl.x". Whitespace/newlines in the list are trimmed.
|
// → "pl.id, pl.x". Whitespace/newlines in the list are trimmed.
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ type scanner interface {
|
|||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryer is the read surface shared by *sql.DB and *sql.Tx, so a list helper can
|
|
||||||
// serve both a standalone read and one inside a transaction.
|
|
||||||
type queryer interface {
|
|
||||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanUser reads one users row. is_admin is stored as INTEGER 0/1 (the driver
|
// scanUser reads one users row. is_admin is stored as INTEGER 0/1 (the driver
|
||||||
// returns it as int64, which does not convert straight to bool), so it is read
|
// returns it as int64, which does not convert straight to bool), so it is read
|
||||||
// into an int64 and mapped.
|
// into an int64 and mapped.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Modal } from '@/components/ui/Modal'
|
|||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { errorMessage } from '@/lib/api'
|
import { errorMessage } from '@/lib/api'
|
||||||
import { useCopyGarden, type Garden } from '@/lib/gardens'
|
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duplicate a garden under a new name. The name is prefilled with the server's
|
* Duplicate a garden under a new name. The name is prefilled with the server's
|
||||||
@@ -17,7 +17,7 @@ import { useCopyGarden, type Garden } from '@/lib/gardens'
|
|||||||
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||||
const copy = useCopyGarden()
|
const copy = useCopyGarden()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [name, setName] = useState(`${garden.name} (copy)`)
|
const [name, setName] = useState(() => defaultCopyName(garden.name))
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent) {
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { defaultCopyName } from './gardens'
|
||||||
|
|
||||||
|
// The server caps a garden name at 200 BYTES and derives a copy's name the same
|
||||||
|
// way (copyName in internal/service/gardens.go). These cases pin the mirror: an
|
||||||
|
// over-long prefill would come back as a 400 rather than a copy.
|
||||||
|
describe('defaultCopyName', () => {
|
||||||
|
it('appends the suffix to a short name', () => {
|
||||||
|
expect(defaultCopyName('Home')).toBe('Home (copy)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a derived name within the 200-byte cap', () => {
|
||||||
|
const name = 'a'.repeat(200)
|
||||||
|
const derived = defaultCopyName(name)
|
||||||
|
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
|
||||||
|
expect(derived.endsWith(' (copy)')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates multi-byte names on a code-point boundary', () => {
|
||||||
|
const name = 'é'.repeat(100) // 200 bytes, exactly at the cap
|
||||||
|
const derived = defaultCopyName(name)
|
||||||
|
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
|
||||||
|
// No replacement character: nothing was cut mid-code-point.
|
||||||
|
expect(derived).not.toContain('�')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not leave a dangling space before the suffix', () => {
|
||||||
|
const name = 'x'.repeat(192) + ' y' // truncation lands on the space
|
||||||
|
expect(defaultCopyName(name)).toBe('x'.repeat(192) + ' (copy)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -86,6 +86,36 @@ export function useUpdateGarden() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the server's garden-name cap (maxGardenNameLen in
|
||||||
|
// internal/service/gardens.go). It is a BYTE cap, not a character count.
|
||||||
|
const MAX_GARDEN_NAME_BYTES = 200
|
||||||
|
const COPY_SUFFIX = ' (copy)'
|
||||||
|
|
||||||
|
/** Trim s to at most maxBytes of UTF-8, never splitting a code point. */
|
||||||
|
function truncateUtf8(s: string, maxBytes: number): string {
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
if (enc.encode(s).length <= maxBytes) return s
|
||||||
|
let out = ''
|
||||||
|
let used = 0
|
||||||
|
for (const ch of s) {
|
||||||
|
const size = enc.encode(ch).length
|
||||||
|
if (used + size > maxBytes) break
|
||||||
|
out += ch
|
||||||
|
used += size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a copy gets by default. Mirrors the server's copyName (same file as
|
||||||
|
* the cap above) so the prefilled value is one the server will accept — without
|
||||||
|
* the byte-capped truncation, copying a garden whose name is already at the cap
|
||||||
|
* would prefill an over-long name and fail with a 400.
|
||||||
|
*/
|
||||||
|
export function defaultCopyName(name: string): string {
|
||||||
|
return truncateUtf8(name, MAX_GARDEN_NAME_BYTES - COPY_SUFFIX.length).trim() + COPY_SUFFIX
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duplicate a garden the actor owns, including its objects and current plantings.
|
* Duplicate a garden the actor owns, including its objects and current plantings.
|
||||||
* An omitted/blank name lets the server derive "<source> (copy)". The copy does
|
* An omitted/blank name lets the server derive "<source> (copy)". The copy does
|
||||||
|
|||||||
Reference in New Issue
Block a user