|
@@ -11,6 +11,7 @@ import (
|
|
|
"fmt"
|
|
"fmt"
|
|
|
"path/filepath"
|
|
"path/filepath"
|
|
|
"strings"
|
|
"strings"
|
|
|
|
|
+ "unicode"
|
|
|
|
|
|
|
|
"git.monoexp.com/bajdev/mpckit/internal/xpj"
|
|
"git.monoexp.com/bajdev/mpckit/internal/xpj"
|
|
|
)
|
|
)
|
|
@@ -62,6 +63,8 @@ type Project struct {
|
|
|
|
|
|
|
|
file *xpj.File
|
|
file *xpj.File
|
|
|
data *xpj.Node
|
|
data *xpj.Node
|
|
|
|
|
+ // The lists every operation works on, found once by wrap.
|
|
|
|
|
+ nodes struct{ pool, trackPool, pads, sequences *xpj.Node }
|
|
|
|
|
|
|
|
lastBPM float64 // BPM of the most recently edited sequence; 0 if none
|
|
lastBPM float64 // BPM of the most recently edited sequence; 0 if none
|
|
|
}
|
|
}
|
|
@@ -126,11 +129,12 @@ func Load(path string) (*Project, []string, error) {
|
|
|
if f.Header[1] != xpj.TestedHeader[1] {
|
|
if f.Header[1] != xpj.TestedHeader[1] {
|
|
|
warnings = append(warnings, fmt.Sprintf("saved by firmware %s; MPCkit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
|
|
warnings = append(warnings, fmt.Sprintf("saved by firmware %s; MPCkit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
|
|
|
}
|
|
}
|
|
|
- if v, err := p.data.Lookup("version").Int(); err != nil || v != TestedVersion {
|
|
|
|
|
|
|
+ if v, err := p.data.Lookup("version").Int(); err != nil {
|
|
|
|
|
+ warnings = append(warnings, fmt.Sprintf("project data version is missing; MPCkit is tested with %d", TestedVersion))
|
|
|
|
|
+ } else if v != TestedVersion {
|
|
|
warnings = append(warnings, fmt.Sprintf("project data version %d; MPCkit is tested with %d", v, TestedVersion))
|
|
warnings = append(warnings, fmt.Sprintf("project data version %d; MPCkit is tested with %d", v, TestedVersion))
|
|
|
}
|
|
}
|
|
|
- for _, e := range p.pool().Items {
|
|
|
|
|
- file := e.Lookup("path").Text
|
|
|
|
|
|
|
+ for _, file := range p.PoolFiles() {
|
|
|
p.Sources[file] = filepath.Join(dataDir, file)
|
|
p.Sources[file] = filepath.Join(dataDir, file)
|
|
|
}
|
|
}
|
|
|
return p, warnings, nil
|
|
return p, warnings, nil
|
|
@@ -142,21 +146,124 @@ func wrap(f *xpj.File, name, sourceDir string) (*Project, error) {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
|
p := &Project{Name: name, SourceDir: sourceDir, Sources: map[string]string{}, file: f, data: d}
|
|
p := &Project{Name: name, SourceDir: sourceDir, Sources: map[string]string{}, file: f, data: d}
|
|
|
- for _, path := range []string{"samples", "tracks[0].samples", "tracks[0].program.drum.instruments", "sequences"} {
|
|
|
|
|
- n, err := d.Get(path)
|
|
|
|
|
|
|
+ for _, l := range []struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ node **xpj.Node
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"samples", &p.nodes.pool},
|
|
|
|
|
+ {"tracks[0].samples", &p.nodes.trackPool},
|
|
|
|
|
+ {"tracks[0].program.drum.instruments", &p.nodes.pads},
|
|
|
|
|
+ {"sequences", &p.nodes.sequences},
|
|
|
|
|
+ } {
|
|
|
|
|
+ n, err := d.Get(l.path)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, fmt.Errorf("project: %w", err)
|
|
return nil, fmt.Errorf("project: %w", err)
|
|
|
}
|
|
}
|
|
|
if n.Kind != xpj.Array {
|
|
if n.Kind != xpj.Array {
|
|
|
- return nil, fmt.Errorf("project: %s is not a list", path)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("project: %s is not a list", l.path)
|
|
|
}
|
|
}
|
|
|
|
|
+ *l.node = n
|
|
|
}
|
|
}
|
|
|
if n := len(p.pads().Items); n != Pads {
|
|
if n := len(p.pads().Items); n != Pads {
|
|
|
return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
|
|
return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
|
|
|
}
|
|
}
|
|
|
|
|
+ if err := p.check(); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("project: %w", err)
|
|
|
|
|
+ }
|
|
|
return p, nil
|
|
return p, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// check refuses a file whose sample names or sequences the operations
|
|
|
|
|
+// cannot use safely. Pool file names are joined to folders at load and
|
|
|
|
|
+// export, so a name such as "../x.wav" would reach outside the project.
|
|
|
|
|
+func (p *Project) check() error {
|
|
|
|
|
+ pools := []struct {
|
|
|
|
|
+ path string
|
|
|
|
|
+ node *xpj.Node
|
|
|
|
|
+ }{{"samples", p.pool()}, {"tracks[0].samples", p.trackPool()}}
|
|
|
|
|
+ for _, pool := range pools {
|
|
|
|
|
+ for i, e := range pool.node.Items {
|
|
|
|
|
+ at := fmt.Sprintf("%s[%d].path", pool.path, i)
|
|
|
|
|
+ n := e.Lookup("path")
|
|
|
|
|
+ if n == nil || n.Kind != xpj.String {
|
|
|
|
|
+ return fmt.Errorf("%s is missing or not text", at)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := PlainFileName(n.Text); err != nil {
|
|
|
|
|
+ return fmt.Errorf("%s: %w", at, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for slot, pad := range p.pads().Items {
|
|
|
|
|
+ layers := pad.Lookup("layersv")
|
|
|
|
|
+ if layers == nil {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ for i, l := range layers.Items {
|
|
|
|
|
+ // An empty name is an empty layer.
|
|
|
|
|
+ n := l.Lookup("sampleFile")
|
|
|
|
|
+ if n == nil {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ at := fmt.Sprintf("pad %s layer %d sampleFile", SlotName(slot), i+1)
|
|
|
|
|
+ if n.Kind != xpj.String {
|
|
|
|
|
+ return fmt.Errorf("%s is not text", at)
|
|
|
|
|
+ }
|
|
|
|
|
+ if n.Text == "" {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := PlainFileName(n.Text); err != nil {
|
|
|
|
|
+ return fmt.Errorf("%s: %w", at, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for i, s := range p.sequences().Items {
|
|
|
|
|
+ if err := checkSequence(s); err != nil {
|
|
|
|
|
+ return fmt.Errorf("sequences[%d]: %w", i, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkSequence checks the fields Sequences and the sequence edits read.
|
|
|
|
|
+func checkSequence(s *xpj.Node) error {
|
|
|
|
|
+ if _, err := s.Lookup("key").Int(); err != nil {
|
|
|
|
|
+ return errors.New("key is missing or not a whole number")
|
|
|
|
|
+ }
|
|
|
|
|
+ v := s.Lookup("value")
|
|
|
|
|
+ if v == nil || v.Kind != xpj.Object {
|
|
|
|
|
+ return errors.New("value is missing or not an object")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := v.Lookup("bpm").Float(); err != nil {
|
|
|
|
|
+ return errors.New("bpm is missing or not a number")
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := v.Lookup("lengthBars").Int(); err != nil {
|
|
|
|
|
+ return errors.New("lengthBars is missing or not a whole number")
|
|
|
|
|
+ }
|
|
|
|
|
+ if n := v.Lookup("name"); n == nil || n.Kind != xpj.String {
|
|
|
|
|
+ return errors.New("name is missing or not text")
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PlainFileName checks that name is a single file name with no folder part,
|
|
|
|
|
+// safe to join to a folder on any system: not empty, not "." or "..", and
|
|
|
|
|
+// free of slashes, backslashes and control characters.
|
|
|
|
|
+func PlainFileName(name string) error {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case name == "":
|
|
|
|
|
+ return errors.New("file name is empty")
|
|
|
|
|
+ case name == "." || name == "..":
|
|
|
|
|
+ return fmt.Errorf("%q is not a file name", name)
|
|
|
|
|
+ case strings.ContainsAny(name, `/\`):
|
|
|
|
|
+ return fmt.Errorf("file name %q contains a folder separator", name)
|
|
|
|
|
+ case strings.IndexFunc(name, unicode.IsControl) >= 0:
|
|
|
|
|
+ return fmt.Errorf("file name %q contains a control character", name)
|
|
|
|
|
+ case filepath.Base(name) != name:
|
|
|
|
|
+ return fmt.Errorf("%q is not a plain file name", name)
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// File returns the project file for writing. Callers must not edit it.
|
|
// File returns the project file for writing. Callers must not edit it.
|
|
|
func (p *Project) File() *xpj.File { return p.file }
|
|
func (p *Project) File() *xpj.File { return p.file }
|
|
|
|
|
|
|
@@ -216,10 +323,7 @@ func checkSlot(slot int) error {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func (p *Project) pads() *xpj.Node {
|
|
|
|
|
- n, _ := p.data.Get("tracks[0].program.drum.instruments")
|
|
|
|
|
- return n
|
|
|
|
|
-}
|
|
|
|
|
|
|
+func (p *Project) pads() *xpj.Node { return p.nodes.pads }
|
|
|
|
|
|
|
|
func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
|
|
func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
|
|
|
|
|
|
|
@@ -230,20 +334,11 @@ func layer(pad *xpj.Node) (*xpj.Node, error) {
|
|
|
// pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
|
|
// pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
|
|
|
// the same entries in both, appending to the first and prepending to the
|
|
// the same entries in both, appending to the first and prepending to the
|
|
|
// second.
|
|
// second.
|
|
|
-func (p *Project) pool() *xpj.Node {
|
|
|
|
|
- n, _ := p.data.Get("samples")
|
|
|
|
|
- return n
|
|
|
|
|
-}
|
|
|
|
|
|
|
+func (p *Project) pool() *xpj.Node { return p.nodes.pool }
|
|
|
|
|
|
|
|
-func (p *Project) trackPool() *xpj.Node {
|
|
|
|
|
- n, _ := p.data.Get("tracks[0].samples")
|
|
|
|
|
- return n
|
|
|
|
|
-}
|
|
|
|
|
|
|
+func (p *Project) trackPool() *xpj.Node { return p.nodes.trackPool }
|
|
|
|
|
|
|
|
-func (p *Project) sequences() *xpj.Node {
|
|
|
|
|
- n, _ := p.data.Get("sequences")
|
|
|
|
|
- return n
|
|
|
|
|
-}
|
|
|
|
|
|
|
+func (p *Project) sequences() *xpj.Node { return p.nodes.sequences }
|
|
|
|
|
|
|
|
// edit applies several Sets and keeps the first error, so an operation can
|
|
// edit applies several Sets and keeps the first error, so an operation can
|
|
|
// list its writes plainly. Paths come from the template; a failure means the
|
|
// list its writes plainly. Paths come from the template; a failure means the
|