Copy a garden (deep duplicate) from the gardens list
Adds POST /gardens/:id/copy: duplicates a garden the actor owns, along
with its objects and their currently-planted plops, into a new garden
owned by the actor. A blank name derives "<source> (copy)".
Deliberately not carried over:
* public_token — the share link is a capability granted to the
original; a copy must not silently inherit a live public URL
* garden_shares — a copy is private to whoever made it
* removed plantings — the copy is a fresh layout, not a history
Owner-only for now: a copy's plops keep pointing at the SOURCE's
plant_ids, so letting a viewer copy someone else's garden would hand
them plants outside their catalog and pin the original owner's plants
against deletion (plantings.plant_id is ON DELETE RESTRICT). Copying a
shared garden needs a plant-cloning policy first.
The whole copy runs in one transaction, so a failure part-way leaves no
half-populated garden. The object/planting list scans are factored into
queryObjects/queryPlantings so the same code serves a plain read and a
read inside that transaction.
UI: a Copy action on the owner's garden card opens a modal prefilled
with the derived name, then lands in the new garden.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -156,6 +156,48 @@ func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in
|
||||
return updated, err
|
||||
}
|
||||
|
||||
// CopyGarden duplicates a garden into a new one owned by the actor, carrying
|
||||
// over its dimensions, grid settings, objects and currently-planted plops. A
|
||||
// blank name defaults to "<source> (copy)".
|
||||
//
|
||||
// Owner-only, deliberately: a copy's plops keep pointing at the SOURCE's
|
||||
// plant_ids, and a custom plant is owned by one user. Letting a viewer copy
|
||||
// someone else's garden would hand them a garden referencing plants that aren't
|
||||
// in their catalog — and would block the original owner from ever deleting those
|
||||
// plants (plantings.plant_id is ON DELETE RESTRICT). Copying a shared garden
|
||||
// needs a plant-cloning policy first; until then the owner's own copy is the
|
||||
// case that's unambiguously correct.
|
||||
func (s *Service) CopyGarden(ctx context.Context, actorID, gardenID int64, name string) (*domain.Garden, error) {
|
||||
src, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = copyName(src.Name)
|
||||
}
|
||||
if len(name) > maxGardenNameLen {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
created, err := s.store.CopyGarden(ctx, gardenID, actorID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created.MyRole = roleOwner.String() // the copier owns the copy
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// copyName derives the default name for a copy. A name already at the length cap
|
||||
// is trimmed to make room for the suffix, on a rune boundary so the result stays
|
||||
// valid UTF-8.
|
||||
func copyName(src string) string {
|
||||
const suffix = " (copy)"
|
||||
if room := maxGardenNameLen - len(suffix); len(src) > room {
|
||||
src = strings.TrimSpace(strings.ToValidUTF8(src[:room], ""))
|
||||
}
|
||||
return src + suffix
|
||||
}
|
||||
|
||||
// DeleteGarden removes a garden; only the owner may.
|
||||
func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) error {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user