AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiNote.cs
1using System.Collections.ObjectModel;
2using System.Text.RegularExpressions;
3
4namespace AnkiIO;
5
26public sealed partial class AnkiNote
27{
28 private readonly Dictionary<string, string> fields;
29 private readonly HashSet<string> tags;
30 private readonly List<AnkiCard> cards = [];
31 private readonly ReadOnlyDictionary<string, string> fieldsView;
32 private readonly ReadOnlyCollection<AnkiCard> cardsView;
33 private long? generatedDeckId;
34 private bool cardsHaveBeenGenerated;
35
55 public AnkiNote(AnkiNoteType noteType, IReadOnlyDictionary<string, string> fields, IEnumerable<string>? tags = null, long? id = null, string? guid = null)
56 {
57 ArgumentNullException.ThrowIfNull(noteType);
58 ArgumentNullException.ThrowIfNull(fields);
59 Id = id ?? AnkiId.New();
60 Guid = string.IsNullOrWhiteSpace(guid) ? System.Guid.NewGuid().ToString("N")[..10] : guid;
61 this.fields = new Dictionary<string, string>(StringComparer.Ordinal);
62 foreach (var field in noteType.Fields)
63 {
64 this.fields[field.Name] = fields.TryGetValue(field.Name, out var value) ? value : string.Empty;
65 }
66
67 var unknown = fields.Keys.FirstOrDefault(key => !this.fields.ContainsKey(key));
68 if (unknown is not null)
69 {
70 throw new ArgumentException($"Field '{unknown}' is not defined by note type '{noteType.Name}'.", nameof(fields));
71 }
72
73 this.tags = new HashSet<string>(StringComparer.Ordinal);
74 foreach (var tag in tags ?? [])
75 {
76 ValidateTag(tag, nameof(tags));
77 this.tags.Add(tag);
78 }
79
80 fieldsView = new ReadOnlyDictionary<string, string>(this.fields);
81 cardsView = cards.AsReadOnly();
82 NoteType = noteType;
83 noteType.Freeze();
84 }
85
89 public long Id { get; }
90
97 public string Guid { get; }
98
101 public AnkiNoteType NoteType { get; }
102
110 public IReadOnlyDictionary<string, string> Fields => fieldsView;
111
119 public IReadOnlyCollection<string> Tags => Array.AsReadOnly(tags.Order(StringComparer.Ordinal).ToArray());
120
128 public IReadOnlyList<AnkiCard> Cards => cardsView;
129
142 public void SetField(string name, string value)
143 {
144 ArgumentNullException.ThrowIfNull(value);
145 if (!fields.ContainsKey(name))
146 {
147 throw new ArgumentException($"Field '{name}' is not defined by note type '{NoteType.Name}'.", nameof(name));
148 }
149
150 int[]? clozeOrdinals = null;
151 if (cardsHaveBeenGenerated
152 && NoteType.Kind == AnkiNoteTypeKind.Cloze
153 && string.Equals(name, "Text", StringComparison.Ordinal))
154 {
155 clozeOrdinals = GetClozeOrdinals(value, nameof(value));
156 }
157
158 fields[name] = value;
159 if (clozeOrdinals is not null)
160 {
161 ReconcileClozeCards(clozeOrdinals, generatedDeckId ?? throw new InvalidOperationException("The generated note is not associated with a deck."));
162 }
163 }
164
169 public void AddTag(string tag)
170 {
171 ValidateTag(tag, nameof(tag));
172 tags.Add(tag);
173 }
174
178 public bool RemoveTag(string tag) => tags.Remove(tag);
179
180 internal void GenerateCards(long deckId, string invalidClozeParameterName)
181 {
182 if (NoteType.Kind == AnkiNoteTypeKind.Cloze)
183 {
184 fields.TryGetValue("Text", out var text);
185 var ordinals = GetClozeOrdinals(text ?? string.Empty, invalidClozeParameterName);
186 cards.Clear();
187 cards.AddRange(ordinals.Select(ordinal => CreateNewCard(deckId, ordinal)));
188 generatedDeckId = deckId;
189 cardsHaveBeenGenerated = true;
190 return;
191 }
192
193 cards.Clear();
194 for (var ordinal = 0; ordinal < NoteType.Templates.Count; ordinal++)
195 {
196 cards.Add(CreateNewCard(deckId, ordinal));
197 }
198
199 generatedDeckId = deckId;
200 cardsHaveBeenGenerated = true;
201 }
202
203 internal void RestoreCards(IEnumerable<AnkiCard> restored)
204 {
205 ArgumentNullException.ThrowIfNull(restored);
206 var restoredCards = restored.ToArray();
207 cards.Clear();
208 cards.AddRange(restoredCards);
209 generatedDeckId ??= restoredCards.FirstOrDefault()?.DeckId;
210 cardsHaveBeenGenerated = true;
211 }
212
213 internal void AttachToDeck(long deckId) => generatedDeckId ??= deckId;
214
215 private static int[] GetClozeOrdinals(string text, string invalidClozeParameterName) => ClozePattern().Matches(text)
216 .Select(match => int.TryParse(
217 match.Groups[1].Value,
218 System.Globalization.NumberStyles.None,
219 System.Globalization.CultureInfo.InvariantCulture,
220 out var index)
221 ? index
222 : throw new ArgumentException(
223 $"The cloze Text field contains index '{match.Groups[1].Value}', which is outside the range supported by System.Int32.",
224 invalidClozeParameterName))
225 .Where(index => index > 0)
226 .Select(index => index - 1)
227 .Distinct()
228 .Order()
229 .ToArray();
230
231 private void ReconcileClozeCards(IEnumerable<int> ordinals, long deckId)
232 {
233 var existing = cards
234 .GroupBy(card => card.TemplateOrdinal)
235 .ToDictionary(group => group.Key, group => group.First());
236 var reconciled = ordinals
237 .Select(ordinal => existing.GetValueOrDefault(ordinal) ?? CreateNewCard(deckId, ordinal))
238 .ToArray();
239 cards.Clear();
240 cards.AddRange(reconciled);
241 }
242
243 private AnkiCard CreateNewCard(long deckId, int ordinal) => new(AnkiId.New(), Id, deckId, ordinal, AnkiScheduling.New);
244
245 private static void ValidateTag(string tag, string parameterName)
246 {
247 if (tag is null)
248 {
249 throw new ArgumentNullException(parameterName);
250 }
251
252 if (string.IsNullOrWhiteSpace(tag) || tag.Any(char.IsWhiteSpace))
253 {
254 throw new ArgumentException("Anki tags cannot be blank or contain whitespace.", parameterName);
255 }
256 }
257
258 [GeneratedRegex(@"\{\{c(\d+)::", RegexOptions.CultureInvariant)]
259 private static partial Regex ClozePattern();
260}
Creates positive 64-bit identifiers for new objects or repeatable external imports.
Definition AnkiId.cs:15
static long New()
Creates a positive, process-unique identifier for a new deck, note type, note, or card.
Definition AnkiId.cs:31
Defines the reusable schema and rendering rules shared by a family of Anki notes.
void SetField(string name, string value)
Replaces a defined field value.
Definition AnkiNote.cs:142
IReadOnlyDictionary< string, string > Fields
Gets this note's values keyed by the exact names in AnkiNoteType.Fields.
Definition AnkiNote.cs:110
AnkiNoteType NoteType
Gets the shared model that defines field order, rendering templates, CSS, and card generation.
Definition AnkiNote.cs:101
AnkiNote(AnkiNoteType noteType, IReadOnlyDictionary< string, string > fields, IEnumerable< string >? tags=null, long? id=null, string? guid=null)
Initializes a detached note while preserving its stable identity.
Definition AnkiNote.cs:55
IReadOnlyList< AnkiCard > Cards
Gets the study prompts currently generated for this note.
Definition AnkiNote.cs:128
long Id
Gets the persisted numeric identity shared by every card generated from this note.
Definition AnkiNote.cs:89
bool RemoveTag(string tag)
Removes one exact, case-sensitive note tag.
Definition AnkiNote.cs:178
void AddTag(string tag)
Adds a note-level search/organization tag if it is not already present.
Definition AnkiNote.cs:169
string Guid
Gets Anki's stable text identity used to match the same note during import/update workflows.
Definition AnkiNote.cs:97
IReadOnlyCollection< string > Tags
Gets note-level labels used for organization and search in Anki.
Definition AnkiNote.cs:119
AnkiNoteTypeKind
Specifies how an AnkiNoteType turns one note into study cards.