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;
55 public AnkiNote(
AnkiNoteType noteType, IReadOnlyDictionary<string, string> fields, IEnumerable<string>? tags =
null,
long?
id =
null,
string? guid =
null)
57 ArgumentNullException.ThrowIfNull(noteType);
58 ArgumentNullException.ThrowIfNull(fields);
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)
64 this.fields[field.Name] = fields.TryGetValue(field.Name, out var value) ? value :
string.Empty;
67 var unknown = fields.Keys.FirstOrDefault(key => !this.fields.ContainsKey(key));
68 if (unknown is not
null)
70 throw new ArgumentException($
"Field '{unknown}' is not defined by note type '{noteType.Name}'.", nameof(fields));
73 this.tags =
new HashSet<string>(StringComparer.Ordinal);
74 foreach (var tag
in tags ?? [])
76 ValidateTag(tag, nameof(tags));
80 fieldsView =
new ReadOnlyDictionary<string, string>(this.fields);
81 cardsView = cards.AsReadOnly();
89 public long Id {
get; }
97 public string Guid {
get; }
110 public IReadOnlyDictionary<string, string>
Fields => fieldsView;
119 public IReadOnlyCollection<string>
Tags => Array.AsReadOnly(tags.Order(StringComparer.Ordinal).ToArray());
128 public IReadOnlyList<AnkiCard>
Cards => cardsView;
144 ArgumentNullException.ThrowIfNull(value);
145 if (!fields.ContainsKey(name))
147 throw new ArgumentException($
"Field '{name}' is not defined by note type '{NoteType.Name}'.", nameof(name));
150 int[]? clozeOrdinals =
null;
151 if (cardsHaveBeenGenerated
153 &&
string.Equals(name,
"Text", StringComparison.Ordinal))
155 clozeOrdinals = GetClozeOrdinals(value, nameof(value));
158 fields[name] = value;
159 if (clozeOrdinals is not
null)
161 ReconcileClozeCards(clozeOrdinals, generatedDeckId ??
throw new InvalidOperationException(
"The generated note is not associated with a deck."));
171 ValidateTag(tag, nameof(tag));
180 internal void GenerateCards(
long deckId,
string invalidClozeParameterName)
184 fields.TryGetValue(
"Text", out var text);
185 var ordinals = GetClozeOrdinals(text ??
string.Empty, invalidClozeParameterName);
187 cards.AddRange(ordinals.Select(ordinal => CreateNewCard(deckId, ordinal)));
188 generatedDeckId = deckId;
189 cardsHaveBeenGenerated =
true;
194 for (var ordinal = 0; ordinal <
NoteType.Templates.Count; ordinal++)
196 cards.Add(CreateNewCard(deckId, ordinal));
199 generatedDeckId = deckId;
200 cardsHaveBeenGenerated =
true;
203 internal void RestoreCards(IEnumerable<AnkiCard> restored)
205 ArgumentNullException.ThrowIfNull(restored);
206 var restoredCards = restored.ToArray();
208 cards.AddRange(restoredCards);
209 generatedDeckId ??= restoredCards.FirstOrDefault()?.DeckId;
210 cardsHaveBeenGenerated =
true;
213 internal void AttachToDeck(
long deckId) => generatedDeckId ??= deckId;
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,
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)
231 private void ReconcileClozeCards(IEnumerable<int> ordinals,
long deckId)
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))
240 cards.AddRange(reconciled);
243 private AnkiCard CreateNewCard(
long deckId,
int ordinal) =>
new(AnkiId.New(),
Id, deckId, ordinal, AnkiScheduling.New);
245 private static void ValidateTag(
string tag,
string parameterName)
249 throw new ArgumentNullException(parameterName);
252 if (
string.IsNullOrWhiteSpace(tag) || tag.Any(
char.IsWhiteSpace))
254 throw new ArgumentException(
"Anki tags cannot be blank or contain whitespace.", parameterName);
258 [GeneratedRegex(
@"\{\{c(\d+)::", RegexOptions.CultureInvariant)]
259 private static partial Regex ClozePattern();
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.