38 ArgumentNullException.ThrowIfNull(root);
39 return ValidateCore([root]);
74 ArgumentNullException.ThrowIfNull(roots);
75 var snapshot = roots.ToArray();
76 if (snapshot.Length == 0)
78 throw new ArgumentException(
"At least one deck hierarchy is required.", nameof(roots));
81 if (snapshot.Any(root => root is
null))
83 throw new ArgumentException(
"A deck hierarchy cannot be null.", nameof(roots));
86 return ValidateCore(snapshot);
91 var diagnostics =
new List<AnkiDiagnostic>();
92 var ids =
new HashSet<long>();
93 var noteTypesById =
new Dictionary<long, AnkiNoteType>();
94 var reportedNoteTypeConflicts =
new HashSet<long>();
96 foreach (var deck
in roots.SelectMany(root => root.Traverse()))
98 AddDuplicate(ids, deck.Id,
"ANKI001",
"Duplicate deck ID.", diagnostics, deck.Id);
99 foreach (var note
in deck.Notes)
101 AddDuplicate(ids, note.Id,
"ANKI002",
"Duplicate note ID.", diagnostics, deck.Id, note.Id);
102 if (!noteTypesById.TryAdd(note.NoteType.Id, note.NoteType)
103 && !noteTypesById[note.NoteType.Id].HasEquivalentDefinition(note.NoteType)
104 && reportedNoteTypeConflicts.Add(note.NoteType.Id))
109 $
"Note type ID {note.NoteType.Id} is shared by conflicting definitions.",
112 SuggestedRemediation:
"Assign a unique ID to each distinct note-type definition."));
115 if (note.NoteType.Fields.Count == 0)
117 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI010",
"The note type has no fields.", DeckId: deck.Id, NoteId: note.Id));
120 if (note.NoteType.Templates.Count == 0)
122 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI011",
"The note type has no templates.", DeckId: deck.Id, NoteId: note.Id));
125 ValidateTemplates(note, deck.Id, diagnostics);
126 foreach (var card
in note.Cards)
128 AddDuplicate(ids, card.Id,
"ANKI003",
"Duplicate card ID.", diagnostics, deck.Id, note.Id, card.Id);
129 ValidateScheduling(card, diagnostics);
134 return new AnkiValidationResult(diagnostics);
137 private static void ValidateTemplates(AnkiNote note,
long deckId, List<AnkiDiagnostic> diagnostics)
139 var known = note.NoteType.Fields.Select(field => field.Name).ToHashSet(StringComparer.Ordinal);
140 known.Add(
"FrontSide");
141 foreach (var
template in note.NoteType.Templates)
143 foreach (Match match
in TemplateFieldPattern().Matches(
template.QuestionFormat +
template.AnswerFormat))
145 var name = match.Groups[1].Value.Split(
':', StringSplitOptions.RemoveEmptyEntries)[^1];
146 if (!known.Contains(name))
148 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI020", $
"Template '{template.Name}' references unknown field '{name}'.", DeckId: deckId, NoteId: note.Id, FieldName: name, SuggestedRemediation:
"Add the field or correct the template reference."));
154 private static void ValidateScheduling(AnkiCard card, List<AnkiDiagnostic> diagnostics)
156 var value = card.Scheduling;
157 var knownType = value.Type is AnkiCardType.New or AnkiCardType.Learning or AnkiCardType.Review or
AnkiCardType.Relearning;
158 var activeQueueValid = value.Type
switch
161 AnkiCardType.Learning => value.Queue is AnkiCardQueue.Learning or
AnkiCardQueue.DayLearning,
163 AnkiCardType.Relearning => value.Queue is AnkiCardQueue.Learning or
AnkiCardQueue.DayLearning,
166 var specialQueue = value.Queue is AnkiCardQueue.Suspended
167 or AnkiCardQueue.SiblingBuried
168 or AnkiCardQueue.SchedulerBuried
170 if (!knownType || (!activeQueueValid && !specialQueue))
172 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI030", $
"Card type {value.Type} is inconsistent with queue {value.Queue}.", CardId: card.Id, SuggestedRemediation:
"Use a queue appropriate for the phase, or a supported suspended, buried, or preview queue."));
175 if (value.Type ==
AnkiCardType.New && (value.Interval != 0 || value.Repetitions != 0 || value.Lapses != 0))
177 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI031",
"A new card cannot have review interval, repetitions, or lapses.", CardId: card.Id, SuggestedRemediation:
"Clear review-derived values or select an explicit learning/review type."));
180 if (card.Flag is < 0 or > 7)
182 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI032",
"Card flag must be between 0 and 7.", CardId: card.Id));
185 if (value.Repetitions < 0 || value.Lapses < 0)
187 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error,
"ANKI033",
"Scheduling repetition and lapse counters cannot be negative.", CardId: card.Id, SuggestedRemediation:
"Use zero for an uninitialized counter or a non-negative persisted count."));
191 private static void AddDuplicate(HashSet<long> ids,
long id,
string code,
string message, List<AnkiDiagnostic> diagnostics,
long? deckId =
null,
long? noteId =
null,
long? cardId =
null)
195 diagnostics.Add(
new(
AnkiDiagnosticSeverity.Error, code, message, DeckId: deckId, NoteId: noteId, CardId: cardId, SuggestedRemediation:
"Assign a unique stable identifier."));
199 [GeneratedRegex(
@"\{\{[#/^]?([^{}]+)\}\}", RegexOptions.CultureInvariant)]
200 private static partial Regex TemplateFieldPattern();