AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiValidator.cs
1using System.Text.RegularExpressions;
2
3namespace AnkiIO;
4
18public static partial class AnkiValidator
19{
37 {
38 ArgumentNullException.ThrowIfNull(root);
39 return ValidateCore([root]);
40 }
41
72 public static AnkiValidationResult Validate(IEnumerable<AnkiDeck> roots)
73 {
74 ArgumentNullException.ThrowIfNull(roots);
75 var snapshot = roots.ToArray();
76 if (snapshot.Length == 0)
77 {
78 throw new ArgumentException("At least one deck hierarchy is required.", nameof(roots));
79 }
80
81 if (snapshot.Any(root => root is null))
82 {
83 throw new ArgumentException("A deck hierarchy cannot be null.", nameof(roots));
84 }
85
86 return ValidateCore(snapshot);
87 }
88
89 private static AnkiValidationResult ValidateCore(IReadOnlyList<AnkiDeck> roots)
90 {
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>();
95
96 foreach (var deck in roots.SelectMany(root => root.Traverse()))
97 {
98 AddDuplicate(ids, deck.Id, "ANKI001", "Duplicate deck ID.", diagnostics, deck.Id);
99 foreach (var note in deck.Notes)
100 {
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))
105 {
106 diagnostics.Add(new(
108 "ANKI004",
109 $"Note type ID {note.NoteType.Id} is shared by conflicting definitions.",
110 DeckId: deck.Id,
111 NoteId: note.Id,
112 SuggestedRemediation: "Assign a unique ID to each distinct note-type definition."));
113 }
114
115 if (note.NoteType.Fields.Count == 0)
116 {
117 diagnostics.Add(new(AnkiDiagnosticSeverity.Error, "ANKI010", "The note type has no fields.", DeckId: deck.Id, NoteId: note.Id));
118 }
119
120 if (note.NoteType.Templates.Count == 0)
121 {
122 diagnostics.Add(new(AnkiDiagnosticSeverity.Error, "ANKI011", "The note type has no templates.", DeckId: deck.Id, NoteId: note.Id));
123 }
124
125 ValidateTemplates(note, deck.Id, diagnostics);
126 foreach (var card in note.Cards)
127 {
128 AddDuplicate(ids, card.Id, "ANKI003", "Duplicate card ID.", diagnostics, deck.Id, note.Id, card.Id);
129 ValidateScheduling(card, diagnostics);
130 }
131 }
132 }
133
134 return new AnkiValidationResult(diagnostics);
135 }
136
137 private static void ValidateTemplates(AnkiNote note, long deckId, List<AnkiDiagnostic> diagnostics)
138 {
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)
142 {
143 foreach (Match match in TemplateFieldPattern().Matches(template.QuestionFormat + template.AnswerFormat))
144 {
145 var name = match.Groups[1].Value.Split(':', StringSplitOptions.RemoveEmptyEntries)[^1];
146 if (!known.Contains(name))
147 {
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."));
149 }
150 }
151 }
152 }
153
154 private static void ValidateScheduling(AnkiCard card, List<AnkiDiagnostic> diagnostics)
155 {
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
159 {
160 AnkiCardType.New => value.Queue == AnkiCardQueue.New,
161 AnkiCardType.Learning => value.Queue is AnkiCardQueue.Learning or AnkiCardQueue.DayLearning,
162 AnkiCardType.Review => value.Queue == AnkiCardQueue.Review,
163 AnkiCardType.Relearning => value.Queue is AnkiCardQueue.Learning or AnkiCardQueue.DayLearning,
164 _ => false,
165 };
166 var specialQueue = value.Queue is AnkiCardQueue.Suspended
167 or AnkiCardQueue.SiblingBuried
168 or AnkiCardQueue.SchedulerBuried
169 or AnkiCardQueue.Preview;
170 if (!knownType || (!activeQueueValid && !specialQueue))
171 {
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."));
173 }
174
175 if (value.Type == AnkiCardType.New && (value.Interval != 0 || value.Repetitions != 0 || value.Lapses != 0))
176 {
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."));
178 }
179
180 if (card.Flag is < 0 or > 7)
181 {
182 diagnostics.Add(new(AnkiDiagnosticSeverity.Error, "ANKI032", "Card flag must be between 0 and 7.", CardId: card.Id));
183 }
184
185 if (value.Repetitions < 0 || value.Lapses < 0)
186 {
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."));
188 }
189 }
190
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)
192 {
193 if (!ids.Add(id))
194 {
195 diagnostics.Add(new(AnkiDiagnosticSeverity.Error, code, message, DeckId: deckId, NoteId: noteId, CardId: cardId, SuggestedRemediation: "Assign a unique stable identifier."));
196 }
197 }
198
199 [GeneratedRegex(@"\{\{[#/^]?([^{}]+)\}\}", RegexOptions.CultureInvariant)]
200 private static partial Regex TemplateFieldPattern();
201}
Builds one named deck hierarchy and acts as the root for validation and export.
Definition AnkiDeck.cs:31
Captures the ordered diagnostics and write/no-write decision from one validation pass.
Checks a complete deck hierarchy before native JSON, CrowdAnki-style JSON, or APKG output.
static AnkiValidationResult Validate(AnkiDeck root)
Validates a root deck and every reachable descendant without modifying them.
static AnkiValidationResult Validate(IEnumerable< AnkiDeck > roots)
Validates several top-level deck hierarchies as one output graph.
AnkiCardType
Identifies the learning phase retained by an Anki card.
AnkiCardQueue
Identifies the active, inactive, or preview queue in which Anki stores a card.
AnkiDiagnosticSeverity
Classifies whether a diagnostic is explanatory, lossy, or blocks a validated write.