git/intake/main
internal/sheet/sheet.go
271 lines9 KB last change raw · history
// 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
}