-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheditor.go
More file actions
1365 lines (1313 loc) · 70.4 KB
/
Copy patheditor.go
File metadata and controls
1365 lines (1313 loc) · 70.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package waxlabel
import (
"cmp"
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/colespringer/waxlabel/internal/core"
"github.com/colespringer/waxlabel/internal/mapping"
"github.com/colespringer/waxlabel/tag"
"github.com/colespringer/waxlabel/waxerr"
)
// ResolveAlias returns the canonical key for a recognized alternative tag spelling
// (DATE/YEAR -> RECORDINGDATE, TOTALTRACKS -> TRACKTOTAL, ORGANIZATION -> LABEL, ...),
// or key unchanged when it is not an alias. Front-ends use it before applying an edit
// so an alias targets the real field instead of creating a duplicate custom field.
func ResolveAlias(key tag.Key) tag.Key { return mapping.ResolveAlias(key) }
// Editor records mutations against a [Document] without changing it. Mutations
// accumulate as a presence-aware [tag.TagPatch] (for canonical fields) plus a
// working picture list; [Editor.Prepare] resolves them into a [Plan]. The
// editor methods return the editor for chaining.
type Editor struct {
doc *Document
base *core.Media
patch tag.TagPatch
pictures []core.Picture
picsTouched bool
// addedMask is parallel to pictures: addedMask[i] is true when pictures[i] was
// added on this editor via AddPicture (so Prepare validates it), false for a
// picture Edit seeded from the file. A mask rather than a second slice lets
// RemovePictures filter both in lockstep with a single evaluation of the caller's
// match predicate, so a side-effecting or non-deterministic matcher cannot be
// called twice or desync the added set from what will be written.
addedMask []bool
chapters []core.Chapter
chaptersTouched bool
syncedLyrics []core.SyncedLyrics
syncedLyricsTouched bool
// syncedLyricsCleared marks that the synced-lyrics set was explicitly cleared before this
// edit, so an ID3 SYLT rewrite must not fall back to the destination's existing SYLT
// language and descriptor: a clear means "start fresh," so an authored set with no language
// reads back with none rather than silently inheriting the cleared one. A plain authored
// set (no preceding clear) leaves this false and keeps that inheritance convenience.
syncedLyricsCleared bool
// carried marks this editor as a faithful carry from a source (the transfer
// engine), not a user-authored edit, so [Editor.Prepare] suppresses the edit-time
// sanity warnings that flag authoring mistakes - the chapter past-duration /
// duplicate-start checks and the single-valued-multi note. Copying a file must not
// lecture about metadata the user authored none of (a source's own conflicting
// single-valued key, or its chapter timings).
carried bool
// syncedLyricsDroppedLines records the 1-based line numbers of authored LRC input that
// produced no timed lyric and were dropped (a front-end parse diagnostic, set via
// [Editor.NoteSyncedLyricsDropped]). [Editor.Prepare] surfaces it as a
// WarnSyncedLyricsLineDropped so a partial input drop does not pass silently.
syncedLyricsDroppedLines []int
// pictureSelectorMisses records cover-art role names a removal named that matched no picture
// in this file (set via [Editor.NotePictureSelectorMiss]). [Editor.Prepare] surfaces each as a
// WarnPictureSelectorMiss so a role that removed nothing is visible rather than a silent no-op.
pictureSelectorMisses []string
}
// Apply records an explicit patch (set/clear/add operations) after any already
// recorded, so later edits win on conflicts. Each operation's key is resolved through
// [ResolveAlias] first, exactly as the key-taking methods below do, so a patch built with
// an alias spelling (e.g. DATE) lands on the canonical field rather than a custom key.
func (e *Editor) Apply(p tag.TagPatch) *Editor {
e.patch.Append(p.MapKeys(ResolveAlias))
return e
}
// Set replaces a key's values. The key is resolved through [ResolveAlias], so an
// alternative spelling (Set(tag.Key("DATE"), ...)) lands on the canonical field
// (RECORDINGDATE) on every format instead of creating a custom key; a non-alias key is
// unchanged.
//
// Calling Set with no values collapses the key to absent during [Editor.Prepare], matching
// the empty-value cleanup. [Editor.Clear] is the explicit removal call. Set(key, "") is
// distinct: it stores one empty value. A format that cannot store that value may drop it,
// report a removed/no-op change, and let the CLI print an advisory stderr note.
//
// A slash-combined "n/total" on [tag.TrackNumber] or [tag.DiscNumber] is normalized
// at [Editor.Prepare] into the canonical pair (e.g. Set(tag.TrackNumber, "3/12")
// becomes TRACKNUMBER=3 + TRACKTOTAL=12) so every format stores it identically; see
// splitNumberPairs for the precedence rules.
func (e *Editor) Set(key tag.Key, vals ...string) *Editor {
e.patch.Set(ResolveAlias(key), vals...)
return e
}
// Clear removes a key (makes it absent). The key is resolved through [ResolveAlias], so
// clearing an alias spelling removes the canonical field.
func (e *Editor) Clear(key tag.Key) *Editor {
e.patch.Clear(ResolveAlias(key))
return e
}
// Add appends values to a key. The key is resolved through [ResolveAlias], so adding under
// an alias spelling appends to the canonical field.
func (e *Editor) Add(key tag.Key, vals ...string) *Editor {
e.patch.Add(ResolveAlias(key), vals...)
return e
}
// SetTags applies the non-empty fields of a typed [tag.Tags] as sugar (it
// compiles to a patch of Set operations; it cannot clear fields).
func (e *Editor) SetTags(t tag.Tags) *Editor { return e.Apply(t.Patch()) }
// AddPicture appends a picture. Its MIME and dimensions are reconciled with the
// image bytes via an authoritative header sniff ([Picture.SniffAuthoritative]):
// when the bytes are a recognized image the sniffed MIME and dimensions win over
// any the caller set, so a mislabeled cover cannot be embedded under a MIME that
// contradicts it. (A file's stored picture, read by the decoders, keeps its own
// MIME - that path fills only, via [Picture.SniffInto].)
func (e *Editor) AddPicture(p Picture) *Editor {
p.SniffAuthoritative()
// Deep-copy the payload so the editor owns its bytes: the caller passes a Picture by
// value, but Data is a slice aliasing their backing array, so a later mutation of that
// array (or reuse of the buffer) would otherwise change the bytes this edit writes.
// The read side (clonePicturesDeep) already detaches on the way out; this detaches on
// the way in with the same ownership rule.
p.Data = append([]byte(nil), p.Data...)
// Pad the mask for any Edit-seeded pictures not yet covered, then mark this one
// added, keeping addedMask parallel to pictures.
for len(e.addedMask) < len(e.pictures) {
e.addedMask = append(e.addedMask, false)
}
e.pictures = append(e.pictures, p)
e.addedMask = append(e.addedMask, true)
e.picsTouched = true
return e
}
// RemovePictures drops every picture for which match returns true. match is
// evaluated exactly once per picture, and the parallel added-mask is filtered with
// the same verdicts, so an added-then-removed picture is not validated by Prepare
// and a side-effecting/non-deterministic matcher cannot double-fire or desync.
func (e *Editor) RemovePictures(match func(Picture) bool) *Editor {
pics := make([]core.Picture, 0, len(e.pictures))
mask := make([]bool, 0, len(e.pictures))
for i, p := range e.pictures {
// Edit() seeds e.pictures via the shallow core.ClonePictures, so each p.Data still
// aliases the immutable Document's backing array. match is the only place the editor
// hands a Picture to user code, so detach Data for the probe: a predicate that writes
// p.Data then cannot mutate the Document (or race a concurrent doc.Pictures()). The
// retained e.pictures keeps the efficient shallow share; only the probe is a copy.
probe := p
probe.Data = append([]byte(nil), p.Data...)
if match(probe) {
continue
}
pics = append(pics, p)
mask = append(mask, i < len(e.addedMask) && e.addedMask[i])
}
e.pictures, e.addedMask = pics, mask
e.picsTouched = true
return e
}
// ClearPictures removes all pictures.
func (e *Editor) ClearPictures() *Editor {
e.pictures = nil
e.addedMask = nil
e.picsTouched = true
return e
}
// SetChapters replaces the whole chapter list. Chapters are a timeline, so the
// list is sorted by start time (stably, preserving the order of chapters that
// share a start) because an out-of-order argument can lose a start when a container
// encodes spans relative to the previous chapter. A format that cannot write chapters
// reports that through [Capabilities]. Lists above a format's hard count cap are
// rejected at [Editor.Prepare]; ID3 CTOC and MP4 Nero chpl are capped at 255 entries.
func (e *Editor) SetChapters(chs ...Chapter) *Editor {
e.chapters = slices.Clone(chs)
core.SortChaptersByStart(e.chapters)
e.chaptersTouched = true
return e
}
// ClearChapters removes all chapters.
func (e *Editor) ClearChapters() *Editor {
e.chapters = nil
e.chaptersTouched = true
return e
}
// SetSyncedLyrics replaces all synced-lyrics sets. Lines within each set are sorted by
// Time with a stable sort, matching [ParseLRC] and [Editor.SetChapters]. The line slices
// are deep-copied so later caller mutations cannot change the pending edit. A format that
// cannot write synced lyrics reports that through [Capabilities], and [Editor.Prepare]
// rejects the write (or, under [WithAllowUnsupportedDrop], drops the set with a warning).
//
// It leaves the explicit-clear marker untouched, so calling it after [Editor.ClearSyncedLyrics]
// authors a fresh set that does not inherit the destination's existing ID3 SYLT language, while
// a plain SetSyncedLyrics with no preceding clear keeps that inheritance convenience.
func (e *Editor) SetSyncedLyrics(sls ...SyncedLyrics) *Editor {
e.syncedLyrics = make([]core.SyncedLyrics, 0, len(sls))
for _, sl := range sls {
// A set with no lines carries no model value: writers skip it because an empty SYLT
// or SYNCEDLYRICS comment projects to nothing on re-read. Dropping it here keeps the
// authored and rendered counts aligned across codecs, so a plan never reports a set
// it did not write.
if len(sl.Lines) == 0 {
continue
}
sl.Lines = slices.Clone(sl.Lines)
slices.SortStableFunc(sl.Lines, func(a, b SyncedLine) int { return cmp.Compare(a.Time, b.Time) })
e.syncedLyrics = append(e.syncedLyrics, sl)
}
e.syncedLyricsTouched = true
return e
}
// NoteSyncedLyricsDropped records the 1-based line numbers of authored LRC input that produced no
// timed lyric and were dropped, so [Editor.Prepare] surfaces a WarnSyncedLyricsLineDropped. It is a
// front-end diagnostic (the CLI's --synced-lyrics-file parse), carried on the editor rather than the
// SyncedLyrics content type so the library model stays free of a parse-time concern. Passing no line
// numbers is a no-op.
func (e *Editor) NoteSyncedLyricsDropped(lines ...int) *Editor {
if len(lines) > 0 {
e.syncedLyricsDroppedLines = append(e.syncedLyricsDroppedLines, lines...)
}
return e
}
// NotePictureSelectorMiss records cover-art role names a removal named that matched no picture in
// this file, so [Editor.Prepare] surfaces a WarnPictureSelectorMiss per role rather than the removal
// being a silent no-op. It is per-file (a role matched in one file may miss in another), so a
// front-end computes the misses against this file's pictures and hands them here. Passing no roles is
// a no-op.
func (e *Editor) NotePictureSelectorMiss(roles ...string) *Editor {
if len(roles) > 0 {
e.pictureSelectorMisses = append(e.pictureSelectorMisses, roles...)
}
return e
}
// ClearSyncedLyrics removes all synced lyrics. It also marks the set as explicitly cleared,
// so a following [Editor.SetSyncedLyrics] authors a fresh set that does not inherit the
// destination's existing ID3 SYLT language or descriptor. A clear with no following set just
// removes the synced lyrics.
func (e *Editor) ClearSyncedLyrics() *Editor {
e.syncedLyrics = nil
e.syncedLyricsTouched = true
e.syncedLyricsCleared = true
return e
}
// Native returns the native inspection view for the original parsed document.
// It does not include pending editor changes; pictures, tags, or chapters added
// on the editor are visible only after a save and reparse. Structural native
// mutation, such as arbitrary block edits, multiple comment blocks, or vendor
// string edits, is not part of the public editing API.
func (e *Editor) Native() NativeEditor {
return NativeEditor{base: e.base}
}
// Prepare resolves the recorded mutations into a [Plan] under the given write
// options. The plan's [Plan.Report] describes exactly what executing it will
// do; nothing is written yet, and Prepare performs no I/O (the parsed document
// holds everything the planner needs).
func (e *Editor) Prepare(opts ...WriteOption) (*Plan, error) {
wo := resolveWriteOptions(opts)
// Propagate the carry marker so codecs can suppress author-convenience heuristics on a
// faithful transfer (e.g. the ID3 SYLT language fallback). Set at the single transfer
// chokepoint (transfer.go), so every carry path inherits it and authored edits do not.
wo.Carried = e.carried
// Propagate the explicit-clear marker so an ID3 SYLT rewrite skips its language/descriptor
// fallback: a cleared-then-authored set starts fresh instead of inheriting the destination's
// existing SYLT metadata. It is distinct from Carried (a faithful transfer), which would
// mislabel the edit.
wo.SyncedLyricsCleared = e.syncedLyricsCleared
// An editor from a zero-value Document (Document.Edit guards that case) has no
// base media to plan against; report it cleanly rather than deref a nil base
// below.
if e.base == nil {
return nil, fmt.Errorf("%w: document is not initialized; use ParseFile/Parse", waxerr.ErrInvalidData)
}
// Refuse to build a write plan for a file the parser determined has no real audio
// (WarnNoAudioFrames): writing it would re-render metadata around non-audio bytes
// and silently bless a contradictory file. Every editing path - set/plan, lint
// --fix, and a copy's destination editor (transfer.go) - funnels through Prepare, so
// they inherit this one guard (exit 4), making a no-audio file fail to edit just as
// it fails to verify. It is a base-document validity check, not an authored-edit
// warning, so it is not gated on the carried flag. The copy source stays readable: a
// no-audio file is still dumpable and its tags are real, so copying tags out of one is
// allowed (only the destination, which writes, is gated here).
if hasNoAudioWarning(e.base) {
return nil, fmt.Errorf("%w: file has no audio essence; refusing to write metadata to a no-audio file", waxerr.ErrInvalidData)
}
// Validate every key the edit touches before it can reach the native writer
// and corrupt on round-trip (e.g. a key containing '='). The key list is reused by
// the NUL scan below, so it is computed once.
patchKeys := e.patch.Keys()
for _, k := range patchKeys {
if !k.Valid() {
return nil, fmt.Errorf("%w: %q (keys are uppercase ASCII 0x20-0x7D without '=' (spaces and punctuation are allowed, '~' is not); build them with tag.ParseKey or tag.MustKey, which accept any case)", waxerr.ErrInvalidKey, k)
}
}
// Share the native document and properties rather than deep-copying them:
// planning only reads the native (re-cloning the blocks it keeps), so a full
// copy here - which would duplicate every block body, including embedded
// cover art - is pure waste. Only the canonical tags (cloned by the patch)
// and the picture set are replaced.
editedTags := e.patch.Apply(e.base.Tags)
// Collapse any key left present with a zero-length value slice to absent before
// the codec plans or Changes() diffs: a Set/Add of no values on an absent key
// (or a clear-then-empty-add) leaves the key present-but-empty, which no codec
// persists - so without this the plan would diff a phantom add against an
// identical file, reporting a change and bumping mtime over bytes that never
// moved. The scope is strictly zero-length: a present [""] (what `set KEY=`
// produces) is a distinct, CLI-reachable empty value and is left untouched.
dropEmptyValuedKeys(&editedTags)
// Reject a NUL byte or invalid UTF-8 in any value, chapter title, or picture
// description this edit introduces: a NUL silently truncates the field on the
// C-string formats, and invalid UTF-8 is reprojected through the read path (ID3 to
// U+FFFD, an MP4 chapter title to "") so the written result would not equal a fresh
// parse - both would corrupt the write, so they are refused at the source.
if err := e.rejectInvalidValues(editedTags, patchKeys); err != nil {
return nil, err
}
// Trim numeric values introduced by this edit before any number-pair split. That
// keeps the stored value in the same form WaxLabel already uses for validation and
// parsing, while still preserving carried values from the source file.
trimTokenValues(&editedTags, e.patch)
// Normalize a slash-combined "n/total" track or disc number this edit introduced
// into the canonical pair every format stores (see splitNumberPairs). It runs
// after rejectInvalidValues, not before: that scan only covers the patched keys, so
// splitting first would route a NUL from "3/\x00" into an unscanned derived
// TRACKTOTAL and smuggle it past the guard onto a C-string format. Splitting after
// means the NUL is still on the touched TRACKNUMBER and is rejected above.
// The returned conflict warnings (an explicit total disagreeing with a slash-derived
// one) are surfaced below, gated on !e.carried like the other authored warnings.
numberConflicts := splitNumberPairs(&editedTags, e.patch)
edited := &core.Media{
Format: e.base.Format,
Properties: e.base.Properties,
Tags: editedTags,
Pictures: e.base.Pictures,
Chapters: e.base.Chapters,
SyncedLyrics: e.base.SyncedLyrics,
Families: e.base.Families,
// Carry the base's legacy-opaque flag alongside its families: the codec result builders
// recompute both from the bytes they write, but a no-op path that returns this edit-intent
// Media directly must still reflect the file's current legacy state.
LegacyOpaqueContent: e.base.LegacyOpaqueContent,
Warnings: e.base.Warnings,
Native: e.base.Native,
Identity: e.base.Identity,
AudioStart: e.base.AudioStart,
AudioEnd: e.base.AudioEnd,
AudioRanges: e.base.AudioRanges,
}
if e.picsTouched {
edited.Pictures = e.pictures
}
if e.chaptersTouched {
edited.Chapters = e.chapters
}
if e.syncedLyricsTouched {
edited.SyncedLyrics = e.syncedLyrics
}
// Enforce the icon-count rule only when this edit authored the picture set. Tags-only
// edits use the file's existing pictures, so duplicate type-1 or type-2 icons in
// the source file should not block unrelated tag edits or lint fixes. A faithful carry
// authors nothing (like the other carried-suppressed checks above), so a copy must not
// reject the source's own duplicate icons as if the user authored them; lint still flags
// the carried result. Direct picture edits set picsTouched without carried and are
// validated here.
if e.picsTouched && !e.carried {
if err := validatePictures(edited.Pictures); err != nil {
return nil, err
}
}
// Validate only the pictures added on this editor (not the file's pre-existing
// ones, which Edit seeded): a direct caller handing AddPicture empty or junk
// bytes would otherwise have them embedded as application/octet-stream. The CLI
// guards the common mistake earlier in loadCovers; this is the library-side
// safety net. WithUnrecognizedPictures opts a deliberately exotic cover back in
// (and the transfer engine opts out wholesale, carrying already-embedded art).
if !wo.AllowUnrecognizedPictures {
if err := validateAddedPictures(e.pictures, e.addedMask); err != nil {
return nil, err
}
}
codec, ok := core.ForFormat(e.base.Format)
if !ok {
return nil, fmt.Errorf("%w: no writer for %s", waxerr.ErrUnsupportedFormat, e.base.Format)
}
// Compute capabilities once under these write options. The chapter gate below and
// the value-reduction check after planning must read the same write policy.
caps := codec.Capabilities(e.base, wo)
// A whole structural edit the destination cannot store at all is either a hard error
// (the default) or, when the caller opts into dropping unsupported edits, removed with a
// warning so the storable part of the edit still applies (matching how a cross-format
// copy drops what the destination cannot hold). A dropped item builds a fresh edited.X
// and records the drop; its metadata-loss and sanity warnings below are then skipped so
// exactly one warning surfaces per drop. The drops run before the chapter reconcile and
// codec.Plan so the plan sees the storable remainder. A format-incapable destination in a
// transfer is handled earlier (ProjectTransfer marks the item Dropped before it is set),
// so the touched flags are false there and none of this fires.
var chaptersDropped, syncedLyricsDropped, picturesDropped bool
// The structural gates below are skipped for a read-only file. Dropping an item there
// would report the FORMAT's storage limits ("a WMA file cannot store chapters") for a
// write that was never going to happen for a different reason, and would let the edit
// collapse into a silent exit-0 no-op while the same edit to a tag exits 3. Leaving the
// item in place carries the edit down to the codec, whose own refusal names the real
// reason - the single predicate its Capabilities reports ReadOnly from.
structuralGates := !caps.ReadOnly
// Chapters: refuse (or drop) a chapter edit on a format that cannot write chapters,
// whether it has no chapter store or one it only reads (Musepack's SV8 packets). The
// gate is a change against the file's own list, so ClearChapters() on a chapterless
// format stays a harmless no-op while a clear on a read-only store is refused: the
// chapters would otherwise stay and the plan would report nothing.
if structuralGates && e.chaptersTouched && caps.Chapters.Write < core.AccessPartial && !core.EqualChapters(e.chapters, e.base.Chapters) {
if !wo.AllowUnsupportedDrop {
return nil, fmt.Errorf("%w: chapters cannot be written to %s %s file",
waxerr.ErrUnsupportedTag, core.IndefiniteArticle(e.base.Format.String()), e.base.Format)
}
edited.Chapters = e.base.Chapters
chaptersDropped = true
}
// The chapter-count limit stays a hard error even under the drop option: ID3 CTOC and MP4
// Nero chpl use single-byte counts, so allowing 256 entries would produce a malformed
// container, and silently truncating a small deliberate list is worse than refusing.
// Skipped once the whole list is already dropped. Transfers apply the same limit before
// calling SetChapters, which leaves this path for direct edits.
if !chaptersDropped && e.chaptersTouched && caps.Chapters.MaxItems > 0 && len(e.chapters) > caps.Chapters.MaxItems {
return nil, fmt.Errorf("%w: %d chapters exceeds the %d %s can store",
waxerr.ErrUnsupportedTag, len(e.chapters), caps.Chapters.MaxItems, e.base.Format)
}
// Synced lyrics: refuse (or drop) an authored set on a format with no synced-lyrics store.
// MP4 and Matroska can carry timed lyric tracks, but those tracks are outside this
// metadata model. A clear on an unsupported format stays a no-op.
if structuralGates && e.syncedLyricsTouched && len(e.syncedLyrics) > 0 && caps.SyncedLyrics.Write < core.AccessPartial {
if !wo.AllowUnsupportedDrop {
return nil, fmt.Errorf("%w: synced lyrics cannot be written to %s %s file",
waxerr.ErrUnsupportedTag, core.IndefiniteArticle(e.base.Format.String()), e.base.Format)
}
edited.SyncedLyrics = nil
syncedLyricsDropped = true
}
// The synced-lyrics set-count limit stays a hard error (the LRC store holds a single set).
if !syncedLyricsDropped && e.syncedLyricsTouched && caps.SyncedLyrics.MaxItems > 0 && len(e.syncedLyrics) > caps.SyncedLyrics.MaxItems {
return nil, fmt.Errorf("%w: %d synced-lyrics sets exceeds the %d %s can store",
waxerr.ErrUnsupportedTag, len(e.syncedLyrics), caps.SyncedLyrics.MaxItems, e.base.Format)
}
// Cover art: WebM excludes the Attachments element, so a cover edit on it cannot be
// stored. Under the drop option, drop the edit here; otherwise the Matroska writer's
// plan-time cover refusal (keyed on the same absent capability) remains the backstop.
// The gate is a change against the file's own set, like the chapter gate above, so
// clearing the cover a WebM file carries is dropped with a warning rather than
// handed to the writer to refuse.
if structuralGates && wo.AllowUnsupportedDrop && e.picsTouched && caps.Pictures.Write < core.AccessPartial && !core.EqualPictures(e.pictures, e.base.Pictures) {
edited.Pictures = e.base.Pictures
picturesDropped = true
}
// Cover format: a destination that stores pictures but only in certain image formats (MP4's
// covr labels only JPEG/PNG/BMP) drops just the covers it cannot label, keeping any it can,
// so a storable TITLE/chapter edit in the same command still applies - matching how copy
// drops an unrepresentable cover while carrying the rest. Gated on the drop option: without
// it the codec's plan-time checkCoverFormats stays the hard-error backstop (so a direct
// library AddPicture still refuses a GIF). Distinct from the WebM whole-set picturesDropped
// above, which the >= AccessPartial guard and !picturesDropped exclude. Partition once so the
// kept picture slice and its added-mask stay positionally aligned for the sanity warnings.
keptPics, keptMask := e.pictures, e.addedMask
var pictureFormatsDropped bool
var droppedPictureMIMEs []string
if wo.AllowUnsupportedDrop && e.picsTouched && !picturesDropped && len(e.pictures) > 0 &&
caps.Pictures.Write >= core.AccessPartial {
kept, keptIdx, dropped := core.PartitionRepresentable(caps.Pictures, e.pictures)
if len(dropped) > 0 {
mask := make([]bool, len(kept))
for i, orig := range keptIdx {
mask[i] = orig < len(e.addedMask) && e.addedMask[orig]
}
edited.Pictures = kept
keptPics, keptMask = kept, mask
pictureFormatsDropped = true
droppedPictureMIMEs = dropped
}
}
// Picture slots: a destination whose picture store is a set of uniquely-named slots
// (APE's two Cover Art items) holds one picture per slot, and the added-aware
// partition resolves the set here, where which pictures this edit authored is known:
// an added picture claims a slot from a pre-existing same-role one - the edit targets
// the slot, so adding a front cover replaces the file's front rather than losing to
// it - while an added picture left with no slot at all is refused like an
// unrepresentable cover format, or dropped with a warning under the same drop option.
// A displaced pre-existing picture is dropped with its own warning; it is the
// destination's data, so refusing the edit for it would make replacement impossible.
// A faithful transfer never conflicts here: PrepareTransfer filters the source set
// through the same partition before it reaches the editor. Resolving before the plan
// keeps the codec's writer a pure backstop and the added-scoped picture sanity
// warnings below accurate about the set actually written.
var slotDroppedRoles, slotReplacedRoles []core.PictureType
var slotReason string
if structuralGates && e.picsTouched && !picturesDropped && len(keptPics) > 0 &&
caps.Pictures.Write >= core.AccessPartial {
if keptIdx, reason, ok := core.PartitionPictureSlotsEdited(caps.Pictures, keptPics, keptMask); ok && len(keptIdx) < len(keptPics) {
slotReason = reason
keptFlag := make([]bool, len(keptPics))
for _, i := range keptIdx {
keptFlag[i] = true
}
for i, p := range keptPics {
if keptFlag[i] {
continue
}
if i < len(keptMask) && keptMask[i] {
slotDroppedRoles = append(slotDroppedRoles, p.Type)
} else {
slotReplacedRoles = append(slotReplacedRoles, p.Type)
}
}
if len(slotDroppedRoles) > 0 && !wo.AllowUnsupportedDrop {
return nil, fmt.Errorf("%w: the %s picture cannot be stored in %s %s file (%s)",
waxerr.ErrUnsupportedTag, slotDroppedRoles[0],
core.IndefiniteArticle(e.base.Format.String()), e.base.Format, slotReason)
}
kept := make([]core.Picture, 0, len(keptIdx))
mask := make([]bool, 0, len(keptIdx))
for _, i := range keptIdx {
kept = append(kept, keptPics[i])
mask = append(mask, i < len(keptMask) && keptMask[i])
}
edited.Pictures = kept
keptPics, keptMask = kept, mask
}
}
// Truncate an over-cap synced-lyrics set to the modeled per-set line cap before planning,
// so the written file and the plan result agree on the line count. A write-path truncation
// would leave the plan over-counting. Skipped for a set already dropped whole above.
var syncedLyricsTruncated bool
if e.syncedLyricsTouched && !syncedLyricsDropped && len(edited.SyncedLyrics) > 0 {
if capped, truncated := core.TruncateSyncedLyrics(edited.SyncedLyrics); truncated {
edited.SyncedLyrics = capped
syncedLyricsTruncated = true
}
}
// Do not reject a parsed 1-2 byte SYLT language here. Some files store NUL-padded short
// codes, and the writer preserves them on read-then-write; longer values are truncated
// to SYLT's fixed three bytes. The CLI validates author-entered --synced-lyrics-lang
// values before they reach this path.
//
// Reconcile any overlap this chapter edit introduced before planning, so the codec writes a
// non-overlapping list. Inserting a start-only chapter between already-ended chapters leaves
// the preceding chapter's end overlapping the insert; truncating that end to the next start
// fixes both the silent ID3/Matroska overlap and the spurious MP4 chapter-metadata-dropped
// warning at once. Reconcile into a clone (leaving e.chapters untouched, so a repeated
// Prepare() recomputes identically and the note stays deterministic); the !e.carried gate
// preserves faithful-transfer fidelity.
var chaptersReconciled bool
if e.chaptersTouched && !e.carried && !chaptersDropped {
reconciled := core.CloneChapters(e.chapters)
if core.ReconcileChapterOverlaps(reconciled, e.base.Chapters) {
edited.Chapters = reconciled
chaptersReconciled = true
}
}
wp, err := codec.Plan(context.Background(), e.base, edited, wo)
if err != nil {
return nil, err
}
// Surface edit-time chapter sanity warnings (a start past the file end, or two
// chapters sharing a start) on the plan report so they flow through the same
// Warnings path the CLI and JSON already render. Only chapters this edit
// introduces are checked - not the file's pre-existing chapters, which the CLI's
// --add-chapter merges into the SetChapters list (so warning about them would
// flag chapters the user never touched). A faithful carry (the transfer engine)
// authors nothing, so it suppresses these entirely via the carried flag.
// A chapter list dropped whole above skips these too, so the single unsupported drop
// warning is the only signal rather than a flurry of sanity notes about chapters that
// will not be written.
if e.chaptersTouched && !e.carried && !chaptersDropped {
wp.Report.Warnings = appendChapterWarnings(wp.Report.Warnings, e.chapters, e.base.Chapters, e.base.Properties.Duration())
// Matroska/WebM can store explicit chapter end times. A CLI chapter rebuild has
// no end-time syntax, so warn when it replaces ended chapters with open-ended ones.
// Faithful transfer is suppressed by the carried flag above.
if matroskaChapterEndsDropped(e.base.Format, e.chapters, e.base.Chapters) {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnChapterEndsDropped,
"chapters rewrite drops explicit end times (CLI-built chapters are open-ended)")
}
// Warn when this destination cannot store every field in the authored chapter
// list. ChapterLoss is option-independent, so use the capability value already
// computed for the write plan. This reads edited.Chapters (the reconciled list),
// not e.chapters: once a stale interior end is truncated to the next start it is
// inferable, so a start-title format no longer reports a spurious gapped-end loss -
// while a genuine interior gap (End < next.Start) or a pre-existing on-disk overlap
// (not reconciled) still warns. The reconcile note below is the accurate signal.
if loss := caps.Chapters.ChapterLoss; core.ChaptersLoseMetadata(edited.Chapters, loss) {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnChapterMetadataDropped,
core.ChapterMetadataDroppedMessage(loss))
}
// A stale end that overlapped the next start was truncated to keep the written list
// non-overlapping. The user chose "truncate + note," so surface it (informational; it
// does not escalate --strict).
if chaptersReconciled {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnChapterOverlapReconciled,
"a chapter's end overlapped the next chapter's start and was truncated to keep the chapters non-overlapping")
}
}
// A faithful carry still surfaces chapters that overshoot the DESTINATION's playable
// length (a destination-fit signal legitimate for copy), while suppressing the
// source-authoring sanity warnings (duplicate-chapter, single-valued-multi) it authored
// none of. Every copied chapter is authored fresh from the source, so the whole list is
// new; unlike appendChapterWarnings this does not consult an isNew gate (full-struct
// equality against the destination base), which would otherwise skip a copied chapter
// that happens to equal a pre-existing destination one and still overshoots.
if e.chaptersTouched && e.carried && !chaptersDropped {
dur := e.base.Properties.Duration()
for _, c := range core.ChaptersPastDuration(e.chapters, dur) {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnChapterPastDuration,
core.ChapterPastDurationMessage(c.Start, dur))
}
}
// Warn when the destination cannot store every field in the authored synced-lyrics
// list. The LRC store keeps timed text but drops the per-set language and descriptor,
// mirroring the chapter metadata-dropped warning above. SyncedLyricsLoss is
// option-independent, so use the capability value already computed for the write plan.
// A transfer that carries source metadata is already graded in its transfer report.
// A set dropped whole above skips this, so the single unsupported drop warning stands
// alone (the metadata-loss code would otherwise describe a set that is not written).
if e.syncedLyricsTouched && !e.carried && !syncedLyricsDropped {
if loss := caps.SyncedLyrics.SyncedLyricsLoss; core.SyncedLyricsLoseMetadata(e.syncedLyrics, loss) {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnSyncedLyricsMetadataDropped,
core.SyncedLyricsMetadataDroppedMessage())
}
}
// An explicit LegacyStrip destroys whatever lives only in the legacy containers it
// removes, which doc.go's frozen contract says must never happen silently. The keys are
// computed against the EDITED tags, not the parsed ones: this edit may be writing the very
// value the legacy container held, and claiming to lose ALBUM while setting ALBUM would be
// a false alarm - and would fire for nearly every key of a copy, which sets most of the
// source's keys on the destination editor.
//
// Deliberately outside the !e.carried gate below. That flag suppresses warnings which
// lecture about metadata the user authored none of; here the user passed --legacy strip
// themselves and it is the destination's own data that disappears, and copy has its own
// --legacy flag, so suppressing on a carry would reproduce the hole one command over.
//
// lint --fix cannot reach this: PlanLintFix adds LegacyStrip only when neither predicate
// holds (lintfix.go), computed from the same two primitives against the same document, so
// the conditions are exact complements. WAV and AIFF are excluded by construction - they
// reuse LegacyStrip to mean "consolidate into the id3 chunk", and never mark a family
// Legacy - so a strip there stays silent.
if wo.Legacy == core.LegacyStrip {
// A key the parsed file already carried canonically was never held only in the legacy
// container, whatever this edit then did to it. Without the filter, --clear TITLE
// --legacy strip claims TITLE is "held only there" because the edit removed it from
// the authority the rule tests - a loss the user asked for, reported as one the strip
// caused. It is also what makes the lint --fix complement hold: PlanLintFix clears a
// stamped ENCODER, and a legacy container echoing that stamp would otherwise read as
// legacy-only and fail its own fix under --strict.
lost := slices.DeleteFunc(core.LegacyOnlyKeys(e.base.Families, editedTags),
func(k tag.Key) bool { return e.base.Tags.Has(k) })
if len(lost) > 0 || e.base.LegacyOpaqueContent {
wp.Report.Warnings = core.WarnKeyed(wp.Report.Warnings, core.WarnLegacyStripDropped,
core.LegacyStripDroppedMessage(lost, e.base.LegacyOpaqueContent), lost...)
}
}
// Surface the whole-item structural drops and the synced-lyrics truncation recorded above.
// They are appended after planning so they ride the same plan-report Warnings path the CLI
// and JSON render, and so --strict (which reads these codes) escalates a drop or truncation
// to a failure. A drop still surfaces even when the remaining edit is a byte-identical
// no-op, so an all-unstorable set reports the loss instead of silently succeeding.
if chaptersDropped {
msg := core.ChaptersUnsupportedMessage(e.base.Format)
if caps.Chapters.Read != core.AccessNone {
msg = core.ChaptersReadOnlyMessage(e.base.Format, len(e.base.Chapters) > 0)
}
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnChaptersUnsupported, msg)
}
if syncedLyricsDropped {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnSyncedLyricsUnsupported,
core.SyncedLyricsUnsupportedMessage(e.base.Format))
}
if picturesDropped {
msg := core.PictureUnsupportedMessage()
if len(e.base.Pictures) > 0 {
msg = core.PicturesReadOnlyMessage()
}
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnPictureUnsupported, msg)
}
// A cover-format drop must surface its own warning here, independent of whether the picture
// set still changed: when every added cover is unrepresentable the kept set collapses back to
// base, so picturesChanged is false and the codec's checkCoverFormats never runs. Emitting
// from the drop flag (not the plan) keeps the loss visible, names the exact MIMEs like copy's
// report item, and rides WarnPictureUnsupported so --strict escalates it exactly like WebM.
if pictureFormatsDropped {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnPictureUnsupported,
core.UnrepresentableReason(e.base.Format, droppedPictureMIMEs))
}
// Slot losses resolved above: an added picture with no slot, and a pre-existing
// picture an added one displaced. Worded like the APE writer's own backstop warning,
// and emitted from the recorded lists so the loss survives an edit that collapses to
// a byte-identical no-op (an added picture whose whole effect was resolved away).
for _, role := range slotDroppedRoles {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnPictureUnsupported,
fmt.Sprintf("the %s picture was dropped: %s", role, slotReason))
}
for _, role := range slotReplacedRoles {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnPictureUnsupported,
fmt.Sprintf("the file's %s picture was replaced by this edit's picture: %s", role, slotReason))
}
if syncedLyricsTruncated {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnSyncedLyricsTruncated,
core.SyncedLyricsTruncatedMessage())
}
// Surface the front-end-authored input diagnostics: LRC lines dropped during parse, and a
// picture-removal role that matched nothing in this file. Both are user input that did not fully
// apply, carried on the editor by the CLI (a carry authors none of them), so they ride the plan
// report and the CLI's --strict gate escalates them.
if n := len(e.syncedLyricsDroppedLines); n > 0 {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnSyncedLyricsLineDropped, fmt.Sprintf(
"%d synced-lyric line(s) had no timestamp and were dropped (lines: %s)", n, formatLineList(e.syncedLyricsDroppedLines)))
}
for _, role := range e.pictureSelectorMisses {
wp.Report.Warnings = core.Warn(wp.Report.Warnings, core.WarnPictureSelectorMiss, fmt.Sprintf(
"no %s picture to remove; the role matched nothing in this file", role))
}
// Surface edit-time picture sanity warnings for the pictures this edit authored
// (added via AddPicture, tracked by addedMask) - an unrecognized image embedded
// under WithUnrecognizedPictures, an added duplicate, or an added front cover that
// makes a second - so the user sees what a picture edit introduced without being
// lectured about a file's pre-existing art (which stays the linter's whole-set
// concern, mirroring how the chapter checks scope to newly-authored chapters). A
// faithful carry authors nothing, so it suppresses these via the carried flag. A
// picture set dropped whole above skips this too, so the single unsupported drop
// warning is the only signal rather than a sanity note about art that is not written.
if e.picsTouched && !e.carried && !picturesDropped {
// Pass the kept set and its filtered mask (equal to e.pictures/e.addedMask when no cover
// format was dropped): a dropped --force GIF then draws no spurious invalid/duplicate note,
// while covers that survived the drop still warn as authored.
wp.Report.Warnings = appendPictureWarnings(wp.Report.Warnings, keptPics, keptMask)
}
// Surface a known single-valued key the edit leaves holding multiple values as a
// non-fatal plan warning, so a library caller sees the cardinality the typed
// projection would silently collapse to its first value. It names exactly the
// keys the CLI's --strict gate acts on, and lets the CLI read the signal off the
// report once (now also in --json warnings). A faithful carry suppresses it (like the
// chapter checks): a copy must not flag the source's own conflicting single-valued
// key as if the user authored it.
if !e.carried {
// The single-valued-multi check judges against the EDIT INTENT (edited.Tags), not
// the codec's re-projected result: a single-valued key is single-valued by the
// key's own definition regardless of format, and a format that collapses the value
// in its result (Matroska's Info.Title) would otherwise stay silent on the very
// loss the warning exists to surface. Diffing base->intent still avoids
// re-flagging an untouched pre-existing multi.
wp.Report.Warnings = appendSingleValuedWarnings(wp.Report.Warnings, e.base.Tags, edited.Tags)
// The legacy-conflict check, by contrast, judges against the plan's result tags
// (what the codec will actually write): a value the codec re-projects - e.g.
// GENRE=17 written back as the name "Rock" - must not read as a conflict when the
// written value in fact still agrees. Suppressed on a faithful carry like the rest.
result := planResultTags(wp, edited)
wp.Report.Warnings = appendLegacyConflictWarnings(wp.Report.Warnings, e.base.Families, e.patch, result, wo.Legacy)
// Warn when a patched value is reduced by the destination's field-level write
// capability, using the same projected result tags as the legacy conflict check.
wp.Report.Warnings = appendValueReducedWarnings(wp.Report.Warnings, caps, patchKeys, editedTags, result)
// Surface a track/disc total-vs-slash conflict this edit authored (computed at the
// number-pair split above, where the precedence lives). A faithful carry is suppressed
// by the enclosing !e.carried gate: a copy must not flag the source's own values.
wp.Report.Warnings = append(wp.Report.Warnings, numberConflicts...)
}
return &Plan{doc: e.doc, plan: wp, opts: wo}, nil
}
// rejectInvalidValues refuses a NUL byte or invalid UTF-8 in any value, chapter title,
// or picture description this edit introduces. A NUL silently truncates the field when it
// is written to a C-string format (the ID3 frames MP3/WAV/AIFF store, MP4 atoms). Invalid
// UTF-8 is reprojected through the read path - ID3 reads it back as U+FFFD, an MP4 chapter
// title as "" - so a value passed raw to a writer would not round-trip and the result
// would not equal a fresh parse; refusing it at the source keeps the "result == fresh
// parse" guarantee by construction for every format, even those that carry the bytes
// verbatim today. This is the library and transfer counterpart to the CLI's OS-level
// argument guard. The tag scan covers only the keys the patch touches (their resolved
// values are in editedTags); the file's untouched pre-existing tags are not re-judged.
// Pictures are scoped to those added on this editor (addedMask), like the rest of the
// added-picture validation; chapters cover the full edited list, which SetChapters
// replaces wholesale.
func (e *Editor) rejectInvalidValues(editedTags tag.TagSet, keys []tag.Key) error {
for _, k := range keys {
vals, ok := editedTags.Get(k)
if !ok {
continue
}
for _, v := range vals {
if err := checkWritableText(v, fmt.Sprintf("tag value for %q", k)); err != nil {
return err
}
}
}
for i, p := range e.pictures {
if i < len(e.addedMask) && e.addedMask[i] {
if err := checkWritableText(p.Description, "picture description"); err != nil {
return err
}
}
}
for _, c := range e.chapters {
if err := checkWritableText(c.Title, "chapter title"); err != nil {
return err
}
// The Matroska chapter languages are written verbatim into the EBML, and the read
// path sanitizes them on parse, so a freshly authored invalid-UTF-8 language would
// not round-trip - reject it at the source like the title (library-only; the CLI has
// no chapter-language syntax).
if err := checkWritableText(c.Language, "chapter language"); err != nil {
return err
}
if err := checkWritableText(c.LanguageIETF, "chapter IETF language"); err != nil {
return err
}
}
// Synced-lyrics text, descriptor, and language are stored in SYLT or LRC and read back
// through sanitization. Reject newly authored NULs or invalid UTF-8 here, using the
// same rule as chapter titles, so the written values can round-trip through the model.
// SetSyncedLyrics replaces the whole list, so the full edited set is scanned.
for _, sl := range e.syncedLyrics {
if err := checkWritableText(sl.Language, "synced-lyrics language"); err != nil {
return err
}
if err := checkWritableText(sl.Description, "synced-lyrics description"); err != nil {
return err
}
for _, ln := range sl.Lines {
if err := checkWritableText(ln.Text, "synced-lyrics line"); err != nil {
return err
}
}
}
return nil
}
// WritableTextReason returns "" when s can be written faithfully to every supported format,
// else a short reason phrase ("contains a NUL byte" / "contains invalid UTF-8"). It is the
// single source of truth for the NUL / invalid-UTF-8 rule: the internal checkWritableText and
// the public ValidWritableText wrap it in an [waxerr.ErrInvalidData] error, and a front-end (the
// CLI) can read the bare phrase to build its own message without parsing an error string.
func WritableTextReason(s string) string {
if strings.IndexByte(s, 0) >= 0 {
return "contains a NUL byte"
}
if !utf8.ValidString(s) {
return "contains invalid UTF-8"
}
return ""
}
// ValidWritableText reports whether s can be written faithfully to every supported format:
// no NUL byte (which truncates a C-string field) and valid UTF-8 (the read path reprojects
// invalid UTF-8, so it would not round-trip). It returns nil, or an error wrapping
// [waxerr.ErrInvalidData] naming the problem. Editor edits already enforce this on authored
// text; a caller (or a front-end) may pre-check a value with it, or with [WritableTextReason]
// for the bare reason phrase, before building an edit.
func ValidWritableText(s string) error {
if r := WritableTextReason(s); r != "" {
return fmt.Errorf("%w: %s", waxerr.ErrInvalidData, r)
}
return nil
}
// checkWritableText refuses a freshly authored text value WaxLabel cannot faithfully write
// to every format: a NUL byte (truncates a C-string field) or invalid UTF-8 (reprojected
// by the read path, so it would not round-trip). what names the field for the error. A
// value read back through the (sanitizing) parse path is always valid UTF-8, so this fires
// only on CLI/library input freshly authored by the caller. It shares WritableTextReason with
// the public ValidWritableText, so its "<what> contains ..." messages stay in lockstep.
func checkWritableText(s, what string) error {
if r := WritableTextReason(s); r != "" {
return fmt.Errorf("%w: %s %s", waxerr.ErrInvalidData, what, r)
}
return nil
}
// planResultTags returns the tag set the plan will write: the codec's computed
// result when present, else the edited set (a NoOp plan may carry no result). It
// is the same source [Plan.Changes] diffs against, so a warning derived from it
// matches the plan's reported changes.
func planResultTags(wp *core.WritePlan, edited *core.Media) tag.TagSet {
if wp.Result != nil {
return wp.Result.Tags
}
return edited.Tags
}
// appendSingleValuedWarnings adds a WarnSingleValuedMulti for every known
// single-valued key the edit changes into holding more than one value. It diffs base
// against the edit INTENT (the edited tag set), not the codec's re-projected result,
// so a format that collapses the value in its own result (Matroska's Info.Title) is
// still flagged - the cardinality is a property of the key, not the format.
// Diffing against base avoids re-flagging an untouched pre-existing multi (already
// reported by Lint), and the shared [tag.Key.SingleValuedMulti] predicate keeps the
// library warning, the linter's finding, and the CLI's --strict gate from disagreeing.
// Each warning carries the offending key (Warning.Keys) so the gate can name it.
func appendSingleValuedWarnings(ws []core.Warning, base, intent tag.TagSet) []core.Warning {
for _, c := range tag.Diff(base, intent) {
if c.Key.SingleValuedMulti(len(c.New)) {
ws = core.WarnKeyed(ws, core.WarnSingleValuedMulti, fmt.Sprintf(
"%s is single-valued but is being given %d values; the typed projection reads only the first",
c.Key, len(c.New)), c.Key)
}
}
return ws
}
// appendLegacyConflictWarnings flags a canonical key the edit changes whose value is
// also carried in a preserved legacy container the family view surfaces - an ID3v1 or
// APEv2 tag on the ID3-based formats (MP3/AAC) - which the default LegacyPreserve policy
// keeps verbatim, so the legacy copy now disagrees with the freshly written native tag.
// It is driven by the family view, so it covers exactly the legacy containers a codec
// projects into fams; a FLAC trailing ID3v1, which the parser preserves but does not
// project into families, is surfaced by the trailing-id3v1 parse warning and the
// "trailing ID3v1 preservation" operation, not this edit-conflict warning.
//
// It fires only for an EDIT-INTRODUCED divergence: the legacy value agreed with the
// native value before this edit (f.Selected) but the edit changed the written value so
// the legacy copy is no longer among it. A pre-existing disagreement - already
// unselected, e.g. an ID3v1 field the parser truncated to 30 bytes - is the linter's
// conflicting-families job, not this edit-time warning. Agreement is judged with
// [core.FamilySelected] against the plan's result tags (what the codec will actually
// write, not the raw edited value - so a re-projected GENRE=17 that writes back as
// "Rock" does not falsely conflict), the same presence test the parser and linter use,
// so a multi-value key whose legacy value still survives the edit (ID3v2 ARTIST=[A,B]
// against an ID3v1 "B") is not falsely flagged - a slice-equality check would be, since
// each legacy family entry is single-valued by construction (one entry per legacy
// value). It fires only under LegacyPreserve (strip resolves the divergence on write)
// and only for a key the patch touches; clearing a key does not fire it (the native key
// is then absent, which FamilySelected - like the linter - treats as no conflict). The
// value is still written and the legacy container preserved as promised; this only
// surfaces the divergence and the remedy. One warning per conflicting key.
func appendLegacyConflictWarnings(ws []core.Warning, fams []core.FamilyValue, patch tag.TagPatch, result tag.TagSet, legacy core.LegacyPolicy) []core.Warning {
if legacy != core.LegacyPreserve {
return ws
}
seen := map[tag.Key]bool{}
for _, f := range fams {
// Gate on the Legacy marker, not on the family name: APEv2 is a legacy container
// in MP3 but the native, authoritative store in WavPack, Monkey's Audio, and
// Musepack, where an edit writes it directly and there is no divergence to warn
// about. The parser sets Legacy on exactly the entries a rewrite does not update.
if !f.Legacy {
continue
}
// Skip an already-warned key, a key the edit does not touch, a pre-existing
// conflict (!f.Selected - not edit-introduced), or a malformed empty legacy entry.
// Legacy entries are single-valued by construction, so f.Values[0] is the value.
if seen[f.Key] || !patch.Touches(f.Key) || !f.Selected || len(f.Values) == 0 {
continue
}
// No conflict while the legacy value still agrees with the written native values
// (present among them, or the key was cleared) - the same rule the family view uses.
if core.FamilySelected(result, f.Key, f.Values[0]) {
continue
}
seen[f.Key] = true
// The remedy names what actually resolves the conflict. --legacy strip always drops the
// stale container. lint --fix does so too, but only when every legacy container is fully
// redundant with the canonical set; on a mixed file (one redundant, one holding unique
// data) its all-or-nothing strip declines, so it is qualified rather than promised.
ws = core.Warn(ws, core.WarnLegacyConflict, fmt.Sprintf(
"preserved %s tag still holds the old %s value and now conflicts with the edit; use --legacy strip to drop it (lint --fix does so only when the legacy container is fully redundant)",
f.Family, f.Key))
}
return ws
}
// appendValueReducedWarnings reports patched values that the destination stores with
// reduced fidelity. Today that applies to an MP3 ORIGINALDATE written as ID3v2.3,
// where TORY keeps only the year.
//
// The check compares the edited tags with the codec's projected result, so a value that
// already matches the reduced form does not warn. The AccessPartial capability gate
// keeps ordinary canonicalization, such as GENRE=17 becoming "Rock", out of this path.
// The reason text comes from the same Capability.Reason helper used by transfer.
func appendValueReducedWarnings(ws []core.Warning, caps core.Capabilities, patchKeys []tag.Key, edited, result tag.TagSet) []core.Warning {
for _, k := range patchKeys {
editedVals, ok := edited.Get(k)
// Empty values are handled by the empty-value note. If the codec omits one, that
// is not a fidelity reduction.
if !ok || !slices.ContainsFunc(editedVals, func(v string) bool { return v != "" }) {
continue
}
fc := caps.Field(k)