// Package sheet renders and parses the one document a person edits to describe
// an album before it is filed.
//
// It replaces the narrower track-name editor that came first. That one could
// change the two folder names and the text after the track number, and nothing
// else: the album title, the album artist, the year, the disc and track numbers
// and the artist on a single track were all decided from the tags and could only
// be fixed by retagging the files. Everything intake is about to write is
// editable here instead, in one file, at any point before the album is filed.
//
// The format is deliberately dull: 'field: value' lines, whole-line '#' comments,
// and '## n' to begin a track. Nothing is positional, so a block can be moved or
// deleted without the rest shifting underneath it; a value may contain '#' and
// ':' freely because a comment is only ever a whole line.
package sheet
import (
"errors"
"fmt"
"strconv"
"strings"
)
// Track is one file in the album. ID is the identity: it is the position of the
// track in the plan as it was when the sheet was written, and it is what ties an
// edited block back to the audio it describes. It is deliberately not the file
// name, which the user may change, nor the track number, which they may also
// change, nor the row order, which they may sort.
type Track struct {
ID int
Title string
Artist string
Disc int
Number int
File string // the exact name to write; empty means derive it from the title
Source string // shown as a comment; not editable
Derived string // the name intake would use with an empty File; a comment
}
type Sheet struct {
Album string
AlbumArtist string
ArtistDir string
AlbumDir string
Year string
// Cover is a path to an image, the word "placeholder", or "none". Only the
// caller knows what to do with it.
Cover string
Tracks []Track
}
// Changes says which fields differ from the sheet the user started with, so a
// caller can tell "left alone" from "deliberately set to this value".
type Changes struct {
Album bool
AlbumArtist bool
ArtistDir bool
AlbumDir bool
Year bool
Cover bool
}
func (s Sheet) ChangedFrom(before Sheet) Changes {
return Changes{
Album: s.Album != before.Album,
AlbumArtist: s.AlbumArtist != before.AlbumArtist,
ArtistDir: s.ArtistDir != before.ArtistDir,
AlbumDir: s.AlbumDir != before.AlbumDir,
Year: s.Year != before.Year,
Cover: s.Cover != before.Cover,
}
}
// Any reports whether anything at all was changed.
func (c Changes) Any() bool {
return c.Album || c.AlbumArtist || c.ArtistDir || c.AlbumDir || c.Year || c.Cover
}
const header = `# intake album sheet
#
# Edit anything here and save. intake throws away everything it read from the
# tags and uses exactly what this file says, so a wrong album artist or a
# missing year is fixed here rather than by retagging the files.
#
# Whole lines beginning with '#' are comments. A '#' or a ':' inside a value is
# fine. Save an empty file to leave the album alone.
#
# album ALBUM tag, and the folder name unless albumdir says otherwise
# albumartist ALBUMARTIST tag, and the artist folder unless artistdir says
# otherwise
# artistdir the folder under archive/ and library/
# albumdir the folder inside artistdir
# year DATE tag, four digits, or empty
# cover a path to an image, or 'placeholder', or 'none'
#
# Each '## n' block is one file. 'n' says which file, and must not change.
# Delete a whole block to leave that file out of the album.
#
# title TITLE tag; the file name is made from it
# artist ARTIST tag; empty means the album artist
# disc disc number
# track track number, which is the number at the front of the file name
# file the exact name to write; empty means intake names it from the
# title. Uncomment the commented line to set it by hand.
`
// Render writes the sheet a person edits.
func Render(s Sheet) string {
var b strings.Builder
b.WriteString(header)
b.WriteString("\n")
fmt.Fprintf(&b, "album: %s\n", s.Album)
fmt.Fprintf(&b, "albumartist: %s\n", s.AlbumArtist)
fmt.Fprintf(&b, "artistdir: %s\n", s.ArtistDir)
fmt.Fprintf(&b, "albumdir: %s\n", s.AlbumDir)
fmt.Fprintf(&b, "year: %s\n", s.Year)
fmt.Fprintf(&b, "cover: %s\n", s.Cover)
for _, t := range s.Tracks {
fmt.Fprintf(&b, "\n## %d\n", t.ID)
if t.Source != "" {
fmt.Fprintf(&b, "# from: %s\n", t.Source)
}
fmt.Fprintf(&b, "title: %s\n", t.Title)
fmt.Fprintf(&b, "artist: %s\n", t.Artist)
fmt.Fprintf(&b, "disc: %d\n", t.Disc)
fmt.Fprintf(&b, "track: %d\n", t.Number)
if t.File != "" {
fmt.Fprintf(&b, "file: %s\n", t.File)
continue
}
// Commented so that the common case stays empty and the line is there to
// uncomment when a file name really should differ from the title.
name := t.Derived
if name == "" {
name = "(from the title)"
}
fmt.Fprintf(&b, "# file: %s\n", name)
}
return b.String()
}
var albumFields = []string{"album", "albumartist", "artistdir", "albumdir", "year", "cover"}
var trackFields = []string{"title", "artist", "disc", "track", "file"}
// Parse reads an edited sheet back. initial supplies the track identities and
// every value that was not touched. Only whole lines are comments, so a title
// containing '#' survives.
func Parse(text string, initial Sheet) (Sheet, error) {
out := initial
out.Tracks = make([]Track, len(initial.Tracks))
copy(out.Tracks, initial.Tracks)
present := make([]bool, len(out.Tracks))
byID := make(map[int]int, len(out.Tracks))
for i, t := range out.Tracks {
byID[t.ID] = i
}
current := -1
for n, raw := range strings.Split(text, "\n") {
line := n + 1
trimmed := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if trimmed == "" {
continue
}
// '##' before '#' or a track heading looks like a comment.
if !strings.HasPrefix(trimmed, "##") && strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "##") {
id, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(trimmed, "##")))
if err != nil {
return Sheet{}, fmt.Errorf("line %d: %q is not a track number", line, trimmed)
}
i, ok := byID[id]
if !ok {
return Sheet{}, fmt.Errorf("line %d: there is no track %d; the number after '##' says which file the block is about and must not change", line, id)
}
if present[i] {
return Sheet{}, fmt.Errorf("line %d: track %d appears twice", line, id)
}
present[i] = true
current = i
continue
}
key, value, ok := strings.Cut(trimmed, ":")
if !ok {
return Sheet{}, fmt.Errorf("line %d: %q is not a 'field: value' line and does not begin a track", line, trimmed)
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if current < 0 {
switch key {
case "album":
out.Album = value
case "albumartist":
out.AlbumArtist = value
case "artistdir":
out.ArtistDir = value
case "albumdir":
out.AlbumDir = value
case "year":
out.Year = value
case "cover":
out.Cover = value
default:
if contains(trackFields, key) {
return Sheet{}, fmt.Errorf("line %d: %q belongs inside a track block, after a '## n' line", line, key)
}
return Sheet{}, fmt.Errorf("line %d: unknown field %q; the album fields are %s", line, key, strings.Join(albumFields, ", "))
}
continue
}
switch key {
case "title":
out.Tracks[current].Title = value
case "artist":
out.Tracks[current].Artist = value
case "file":
out.Tracks[current].File = value
case "disc":
v, err := strconv.Atoi(value)
if err != nil || v < 1 {
return Sheet{}, fmt.Errorf("line %d: disc %q is not a number of 1 or more", line, value)
}
out.Tracks[current].Disc = v
case "track":
v, err := strconv.Atoi(value)
if err != nil || v < 1 {
return Sheet{}, fmt.Errorf("line %d: track %q is not a number of 1 or more", line, value)
}
out.Tracks[current].Number = v
default:
if contains(albumFields, key) {
return Sheet{}, fmt.Errorf("line %d: %q is an album field and belongs at the top of the file, before the first '## n' line", line, key)
}
return Sheet{}, fmt.Errorf("line %d: unknown track field %q; the track fields are %s", line, key, strings.Join(trackFields, ", "))
}
}
kept := make([]Track, 0, len(out.Tracks))
for i, t := range out.Tracks {
if !present[i] {
continue
}
t.Title = strings.TrimSpace(t.Title)
t.Artist = strings.TrimSpace(t.Artist)
t.File = strings.TrimSpace(t.File)
kept = append(kept, t)
}
if len(kept) == 0 {
return Sheet{}, errors.New("no tracks are left in the sheet; an empty sheet leaves the album alone")
}
out.Tracks = kept
return out, nil
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}