export.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. // Package export writes a project to a folder or a zip file: the .xpj and
  2. // its "<Name>_[ProjectData]" folder holding every pool file (build plan 4.4).
  3. package export
  4. import (
  5. "archive/zip"
  6. "bytes"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "os"
  11. "path/filepath"
  12. "strings"
  13. "time"
  14. "git.monoexp.com/bajdev/mpckit/internal/project"
  15. )
  16. // ExistsError is returned when the destination already holds the project
  17. // and overwriting was not allowed. The caller asks the user, then retries.
  18. type ExistsError struct {
  19. Paths []string
  20. }
  21. func (e *ExistsError) Error() string {
  22. return "already exists: " + strings.Join(e.Paths, ", ")
  23. }
  24. // MissingError lists pool files whose source can no longer be found.
  25. type MissingError struct {
  26. Files []string // pool file names
  27. Paths []string // where each was expected
  28. }
  29. func (e *MissingError) Error() string {
  30. var b strings.Builder
  31. fmt.Fprintf(&b, "%d sample file(s) are missing:", len(e.Files))
  32. for i := range e.Files {
  33. fmt.Fprintf(&b, "\n %s (%s)", e.Files[i], e.Paths[i])
  34. }
  35. return b.String()
  36. }
  37. type item struct {
  38. name string // file name inside the data folder
  39. src string
  40. }
  41. // collect checks that every pool file has a source that exists.
  42. func collect(p *project.Project) ([]item, error) {
  43. var items []item
  44. missing := &MissingError{}
  45. for _, f := range p.PoolFiles() {
  46. src := p.Sources[f]
  47. if src == "" {
  48. missing.Files = append(missing.Files, f)
  49. missing.Paths = append(missing.Paths, "no source recorded")
  50. continue
  51. }
  52. if st, err := os.Stat(src); err != nil || !st.Mode().IsRegular() {
  53. missing.Files = append(missing.Files, f)
  54. missing.Paths = append(missing.Paths, src)
  55. continue
  56. }
  57. items = append(items, item{name: f, src: src})
  58. }
  59. if len(missing.Files) > 0 {
  60. return nil, missing
  61. }
  62. return items, nil
  63. }
  64. func encode(p *project.Project) ([]byte, error) {
  65. var b bytes.Buffer
  66. if err := p.File().Write(&b); err != nil {
  67. return nil, err
  68. }
  69. return b.Bytes(), nil
  70. }
  71. func dataDir(p *project.Project) string { return p.Name + project.DataSuffix }
  72. // Folder writes <dest>/<Name>.xpj and <dest>/<Name>_[ProjectData]/ and
  73. // returns the path of the .xpj. Everything is written under a temporary
  74. // name in dest first and renamed at the end, so a failure leaves any
  75. // existing project as it was. If either target exists and overwrite is
  76. // false, nothing is written and an *ExistsError is returned.
  77. func Folder(p *project.Project, dest string, overwrite bool) (string, error) {
  78. if err := project.ValidName(p.Name); err != nil {
  79. return "", fmt.Errorf("project name: %w", err)
  80. }
  81. items, err := collect(p)
  82. if err != nil {
  83. return "", err
  84. }
  85. xpjPath := filepath.Join(dest, p.Name+".xpj")
  86. dirPath := filepath.Join(dest, dataDir(p))
  87. if !overwrite {
  88. if exists := existing(xpjPath, dirPath); len(exists) > 0 {
  89. return "", &ExistsError{Paths: exists}
  90. }
  91. }
  92. body, err := encode(p)
  93. if err != nil {
  94. return "", err
  95. }
  96. tmp, err := os.MkdirTemp(dest, ".mpckit-export-")
  97. if err != nil {
  98. return "", err
  99. }
  100. defer os.RemoveAll(tmp)
  101. tmpData := filepath.Join(tmp, dataDir(p))
  102. if err := os.Mkdir(tmpData, 0o755); err != nil {
  103. return "", err
  104. }
  105. for _, it := range items {
  106. if err := copyFile(it.src, filepath.Join(tmpData, it.name)); err != nil {
  107. return "", err
  108. }
  109. }
  110. tmpXPJ := filepath.Join(tmp, p.Name+".xpj")
  111. if err := os.WriteFile(tmpXPJ, body, 0o644); err != nil {
  112. return "", err
  113. }
  114. // Replace: the old pair goes only once the new pair is complete.
  115. if overwrite {
  116. if err := os.RemoveAll(dirPath); err != nil {
  117. return "", err
  118. }
  119. if err := os.Remove(xpjPath); err != nil && !errors.Is(err, os.ErrNotExist) {
  120. return "", err
  121. }
  122. }
  123. if err := os.Rename(tmpData, dirPath); err != nil {
  124. return "", err
  125. }
  126. if err := os.Rename(tmpXPJ, xpjPath); err != nil {
  127. return "", err
  128. }
  129. return xpjPath, nil
  130. }
  131. func existing(paths ...string) []string {
  132. var out []string
  133. for _, p := range paths {
  134. if _, err := os.Lstat(p); err == nil {
  135. out = append(out, p)
  136. }
  137. }
  138. return out
  139. }
  140. func copyFile(src, dst string) error {
  141. in, err := os.Open(src)
  142. if err != nil {
  143. return err
  144. }
  145. defer in.Close()
  146. out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
  147. if err != nil {
  148. return err
  149. }
  150. if _, err := io.Copy(out, in); err != nil {
  151. out.Close()
  152. return err
  153. }
  154. return out.Close()
  155. }
  156. // Zip writes a zip file at path holding <Name>.xpj and
  157. // <Name>_[ProjectData]/ at its root, ready to extract onto the device's
  158. // card. It writes to a temporary file and renames it at the end. If path
  159. // exists and overwrite is false, an *ExistsError is returned.
  160. func Zip(p *project.Project, path string, overwrite bool) error {
  161. if err := project.ValidName(p.Name); err != nil {
  162. return fmt.Errorf("project name: %w", err)
  163. }
  164. items, err := collect(p)
  165. if err != nil {
  166. return err
  167. }
  168. if !overwrite {
  169. if exists := existing(path); len(exists) > 0 {
  170. return &ExistsError{Paths: exists}
  171. }
  172. }
  173. body, err := encode(p)
  174. if err != nil {
  175. return err
  176. }
  177. tmp, err := os.CreateTemp(filepath.Dir(path), ".mpckit-export-*.zip")
  178. if err != nil {
  179. return err
  180. }
  181. defer os.Remove(tmp.Name())
  182. if err := writeZip(tmp, p, body, items); err != nil {
  183. tmp.Close()
  184. return err
  185. }
  186. if err := tmp.Close(); err != nil {
  187. return err
  188. }
  189. if err := os.Chmod(tmp.Name(), 0o644); err != nil {
  190. return err
  191. }
  192. return os.Rename(tmp.Name(), path)
  193. }
  194. func writeZip(w io.Writer, p *project.Project, body []byte, items []item) error {
  195. now := time.Now()
  196. zw := zip.NewWriter(w)
  197. // The .xpj is already gzip-compressed: store it.
  198. fw, err := zw.CreateHeader(&zip.FileHeader{Name: p.Name + ".xpj", Method: zip.Store, Modified: now})
  199. if err != nil {
  200. return err
  201. }
  202. if _, err := fw.Write(body); err != nil {
  203. return err
  204. }
  205. dir := dataDir(p) + "/"
  206. if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir, Modified: now}); err != nil {
  207. return err
  208. }
  209. for _, it := range items {
  210. fw, err := zw.CreateHeader(&zip.FileHeader{Name: dir + it.name, Method: zip.Deflate, Modified: now})
  211. if err != nil {
  212. return err
  213. }
  214. in, err := os.Open(it.src)
  215. if err != nil {
  216. return err
  217. }
  218. _, err = io.Copy(fw, in)
  219. in.Close()
  220. if err != nil {
  221. return err
  222. }
  223. }
  224. return zw.Close()
  225. }