Files

309 lines
11 KiB
Go

package editor
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"aego/art"
"aego/asset"
"aego/asset/importers/artsource"
"aego/asset/pipeline"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine/i18n"
"aego/project"
)
// Paths are project-relative. Accept a conventional leading ./ without
// accepting parent traversal, an absolute path, or platform-specific aliases.
func artSavePath(value string) (string, error) {
rel := strings.TrimSpace(value)
for strings.HasPrefix(rel, "./") {
rel = strings.TrimPrefix(rel, "./")
}
if !fs.ValidPath(rel) || !filepath.IsLocal(rel) || strings.ContainsAny(rel, "\\:#") {
return "", fmt.Errorf("art: enter a project-relative source path")
}
return rel, nil
}
// The red label is a hint, never permission to overwrite. SourceBytes and the
// journalled transaction perform the actual validation and preimage checks.
func artSaveReplaces(ed *context.Editor, values []string) bool {
if ed == nil || ed.Project == nil || len(values) != 1 {
return false
}
rel, err := artSavePath(values[0])
if err != nil {
return false
}
root, err := os.OpenRoot(ed.Project.Root)
if err != nil {
return false
}
defer root.Close()
_, err = root.Lstat(rel)
return err == nil || !errors.Is(err, os.ErrNotExist)
}
type artSaveTarget struct {
ed *context.Editor
docs *document.Manager
pipe *pipeline.Pipeline
project *project.Project
owner *document.AssetDocument
path string
before []byte
metaBefore []byte
meta *pipeline.Meta
replacement bool
}
func prepareArtSaveTarget(ed *context.Editor, owner *document.AssetDocument, target string, snapshot *art.Snapshot) (*artSaveTarget, error) {
if ed == nil || ed.Docs == nil || ed.Pipeline == nil || ed.Project == nil || owner == nil || snapshot == nil {
return nil, fmt.Errorf("art: project and drawing are required")
}
var err error
target, err = artSavePath(target)
if err != nil {
return nil, err
}
if !strings.EqualFold(path.Ext(target), art.SourceExtension) {
return nil, fmt.Errorf("%s", i18n.Tr("editor.art.link_extension", "The editable source must use .aeart."))
}
if err := ed.Pipeline.CheckSourceRecovery(); err != nil {
return nil, err
}
d := &artSaveTarget{ed: ed, docs: ed.Docs, pipe: ed.Pipeline, project: ed.Project, owner: owner, path: target}
if d.before, err = d.pipe.SourceBytes(target); err != nil {
return nil, err
}
if d.metaBefore, err = d.pipe.SourceBytes(target + pipeline.MetaSuffix); err != nil {
return nil, err
}
d.replacement = d.before != nil
if d.replacement {
if d.metaBefore == nil {
return nil, fmt.Errorf("%s", i18n.Tr("editor.art.replace_missing_meta", "The existing drawing has no metadata. Resolve its identity before replacing it."))
}
d.meta, err = pipeline.ParseMeta(d.metaBefore, target+pipeline.MetaSuffix)
if err != nil || d.meta.ID.IsZero() || asset.IsBuiltin(d.meta.ID) || d.meta.Importer != "artsource" {
return nil, fmt.Errorf("%s", i18n.Tr("editor.art.replace_invalid_meta", "The existing drawing has invalid or incompatible metadata. It was not replaced."))
}
old, decodeErr := art.Decode(d.before, art.DefaultLimits())
if decodeErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.Tr("editor.art.replace_invalid_source", "The existing drawing could not be read; it was not replaced"), decodeErr)
}
oldInfo := old.Info()
old.Close()
if err := artReplacementKeepsReferences(oldInfo, snapshot.Info()); err != nil {
return nil, err
}
} else {
if d.metaBefore != nil {
return nil, fmt.Errorf("%s", i18n.Tr("editor.art.replace_orphan_meta", "The destination already has metadata but no source. Resolve it or choose another name."))
}
d.meta, err = d.pipe.NewSourceMeta(target)
if err != nil {
return nil, err
}
}
if err := d.verify(); err != nil {
return nil, err
}
return d, nil
}
// A replacement may change pixels, but must not silently remove existing
// frame, channel, or tag identities used by scenes, clips, and linked PNGs.
func artReplacementKeepsReferences(old, next art.Info) error {
names := func(info art.Info) map[string]bool {
channels := map[art.Channel]bool{art.Albedo: true}
for _, cel := range info.Cels {
channels[cel.Channel] = true
}
out := map[string]bool{artsource.ClipName(""): true}
for _, frame := range info.Frames {
for channel := range channels {
out[artsource.FrameName(frame.ID, channel)] = true
}
}
for _, tag := range info.Tags {
out[artsource.ClipName(tag.ID)] = true
}
return out
}
current := names(next)
for name := range names(old) {
if !current[name] {
return fmt.Errorf("%s", i18n.Tr("editor.art.replace_incompatible", "Replacing this drawing would remove existing frame, animation, or surface references. Choose a new name instead."))
}
}
return nil
}
func sameArtSource(a, b []byte) bool {
return (a == nil) == (b == nil) && bytes.Equal(a, b)
}
// Saving back to the currently open source uses its normal BinarySource guard
// and history. Check its sidecar and both path generations before offering or
// accepting the explicit replacement; do not save unrelated dirty metadata.
func artCurrentSaveGuard(ed *context.Editor, doc *document.AssetDocument, before, metaBefore []byte) error {
if ed == nil || ed.Pipeline == nil || ed.Docs == nil || doc == nil || ed.Docs.AssetDocument(doc.Asset) != doc || before == nil || metaBefore == nil {
return fmt.Errorf("art: the current source or its metadata is unavailable")
}
if err := ed.Pipeline.CheckSourceRecovery(); err != nil {
return err
}
if name, ok := ed.Pipeline.PathOf(doc.Asset); !ok || name != doc.Path {
return fmt.Errorf("art: the current source identity changed")
}
meta, err := pipeline.ParseMeta(metaBefore, doc.Path+pipeline.MetaSuffix)
if err != nil || meta.ID != doc.Asset || meta.Importer != "artsource" {
return fmt.Errorf("art: the current source metadata identity changed")
}
for _, entry := range []struct {
name string
data []byte
}{{doc.Path, before}, {doc.Path + pipeline.MetaSuffix, metaBefore}} {
if script := ed.Docs.Script(entry.name); script != nil {
if script.Dirty() || script.Conflict || entry.name == doc.Path {
return fmt.Errorf("art: close or resolve the open source settings before saving: %s", entry.name)
}
normalized := bytes.ReplaceAll(bytes.ReplaceAll(entry.data, []byte("\r\n"), []byte("\n")), []byte("\r"), []byte("\n"))
if !bytes.Equal(script.Code.Bytes(), normalized) {
return fmt.Errorf("art: the open source metadata differs from disk: %s", entry.name)
}
}
if pending, err := ed.Docs.HasScriptRecovery(entry.name); err != nil {
return err
} else if pending {
return fmt.Errorf("art: the source has a pending recovery: %s", entry.name)
}
got, err := ed.Pipeline.SourceBytes(entry.name)
if err != nil || !sameArtSource(got, entry.data) {
return fmt.Errorf("%s", i18n.Tr("editor.art.replace_changed", "The destination changed while saving. Review it and try again."))
}
}
return nil
}
func (d *artSaveTarget) verify() error {
if d.ed.Docs != d.docs || d.ed.Pipeline != d.pipe || d.ed.Project != d.project || d.docs.AssetDocument(d.owner.Asset) != d.owner {
return fmt.Errorf("art: the project or drawing changed before saving")
}
if err := d.pipe.CheckSourceRecovery(); err != nil {
return err
}
for _, name := range []string{d.path, d.path + pipeline.MetaSuffix} {
if script := d.docs.Script(name); script != nil {
// A clean metadata document may have been acquired by Inspector,
// without the drawing itself being opened. Replacement keeps its
// exact metadata bytes, so reuse it only while its baseline agrees.
if !d.replacement || name == d.path || script.Dirty() || script.Conflict {
return fmt.Errorf("art: close or resolve the open destination before saving: %s", name)
}
normalized := bytes.ReplaceAll(bytes.ReplaceAll(d.metaBefore, []byte("\r\n"), []byte("\n")), []byte("\r"), []byte("\n"))
if !bytes.Equal(script.Code.Bytes(), normalized) {
return fmt.Errorf("art: open destination metadata differs from the saved file: %s", name)
}
}
pending, err := d.docs.HasScriptRecovery(name)
if err != nil {
return err
}
if pending {
return fmt.Errorf("art: destination has a pending recovery: %s", name)
}
}
for _, open := range d.docs.AssetDocuments() {
if open != d.owner && (open.Path == d.path || open.Asset == d.meta.ID) {
pending := open.Binding.PendingRecovery != nil && open.Binding.PendingRecovery()
if !d.replacement || open.Dirty() || pending || open.Binding.Model != nil || open.Binding.Close != nil || open.Binding.Save != nil || open.Source != nil || open.Scene != nil || !open.Capabilities.Has(document.EditSource) {
return fmt.Errorf("art: close or resolve the open destination before saving: %s", d.path)
}
}
}
if d.meta.ID == d.owner.Asset || d.meta.ID.IsZero() || asset.IsBuiltin(d.meta.ID) {
return fmt.Errorf("art: destination identity is already in use")
}
pending, err := d.docs.HasAssetRecovery(d.meta.ID)
if err != nil {
return err
}
if pending {
return fmt.Errorf("art: destination has a pending recovery: %s", d.path)
}
for _, entry := range []struct {
path string
data []byte
}{{d.path, d.before}, {d.path + pipeline.MetaSuffix, d.metaBefore}} {
got, err := d.pipe.SourceBytes(entry.path)
if err != nil || !sameArtSource(got, entry.data) {
return fmt.Errorf("%s", i18n.Tr("editor.art.replace_changed", "The destination changed while saving. Review it and try again."))
}
}
if d.replacement {
id, ok := d.pipe.Lookup(d.path)
if !ok || id != d.meta.ID {
return fmt.Errorf("art: existing destination identity is not current; reimport it before replacing")
}
if err := d.pipe.VerifyCurrent(id); err != nil {
return fmt.Errorf("art: resolve the existing destination before replacing: %w", err)
}
for _, id := range d.pipe.Dependents(id) {
if open := d.docs.AssetDocument(id); open != nil && (open.Dirty() || open.Binding.PendingRecovery != nil && open.Binding.PendingRecovery()) {
return fmt.Errorf("art: a dependent asset has unsaved changes or pending recovery: %s", open.Path)
}
if pending, err := d.docs.HasAssetRecovery(id); err != nil {
return err
} else if pending {
return fmt.Errorf("art: a dependent asset has a pending recovery: %s", id.String())
}
}
} else {
if _, ok := d.pipe.Entry(d.meta.ID); ok {
return fmt.Errorf("art: destination identity is already indexed")
}
if _, ok := d.pipe.PathOf(d.meta.ID); ok {
return fmt.Errorf("art: destination identity is already indexed")
}
}
return nil
}
func (d *artSaveTarget) edits(encoded []byte) []pipeline.SourceEdit {
metaAfter := d.metaBefore
if !d.replacement {
metaAfter = pipeline.WriteMeta(d.meta)
}
return []pipeline.SourceEdit{
{Path: d.path, Before: d.before, After: encoded},
{Path: d.path + pipeline.MetaSuffix, Before: d.metaBefore, After: metaAfter, ReadOnlyGuard: d.replacement},
}
}
func (d *artSaveTarget) confirm(publish func()) {
if !d.replacement {
publish()
return
}
d.ed.Ask(context.Question{
Title: i18n.Tr("editor.art.replace_title", "Replace existing drawing?"),
Message: i18n.Tr("editor.art.replace_notice", "Replace {path}? Its current drawing will be overwritten. The asset identity is kept, so all users of this shared asset can change.", i18n.Str("path", d.path)),
Buttons: []string{i18n.Tr("editor.art.replace", "Replace"), i18n.Tr("editor.art.cancel", "Cancel")},
Then: func(choice int) {
if choice == 0 {
publish()
}
},
})
}