AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiDeck.cs
1using System.Collections.ObjectModel;
2using System.Globalization;
3using System.Text.Json;
4
5namespace AnkiIO;
6
30public sealed class AnkiDeck
31{
32 private readonly List<AnkiDeck> subdecks = [];
33 private readonly List<AnkiNote> notes = [];
34 private readonly ReadOnlyCollection<AnkiDeck> subdecksView;
35 private readonly ReadOnlyCollection<AnkiNote> notesView;
36 private ConventionalNoteTypeCache conventionalNoteTypes;
37
50 public AnkiDeck(string name, long? id = null)
51 : this(name, id, new ConventionalNoteTypeCache())
52 {
53 }
54
55 private AnkiDeck(string name, long? id, ConventionalNoteTypeCache conventionalNoteTypes)
56 {
57 ArgumentException.ThrowIfNullOrWhiteSpace(name);
58 if (name.Contains("::", StringComparison.Ordinal))
59 {
60 throw new ArgumentException("A deck segment cannot contain '::'. Build hierarchy with AddSubdeck().", nameof(name));
61 }
62
63 Name = name;
64 Id = id ?? AnkiId.New();
65 Media = new AnkiMediaCollection();
66 subdecksView = subdecks.AsReadOnly();
67 notesView = notes.AsReadOnly();
68 this.conventionalNoteTypes = conventionalNoteTypes;
69 }
70
77 public long Id { get; }
78
81 public string Name { get; }
82
90 public string Description { get; set; } = string.Empty;
91
96 public IDictionary<string, string> Metadata { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
97
103 public IDictionary<string, JsonElement> UnknownData { get; } = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
104
112
115 public IReadOnlyList<AnkiDeck> Subdecks => subdecksView;
116
120 public IReadOnlyList<AnkiNote> Notes => notesView;
121
137 public AnkiDeck AddSubdeck(string name, long? id = null)
138 {
139 if (subdecks.Any(deck => string.Equals(deck.Name, name, StringComparison.OrdinalIgnoreCase)))
140 {
141 throw new ArgumentException($"Subdeck '{name}' already exists.", nameof(name));
142 }
143
144 var deck = new AnkiDeck(name, id, conventionalNoteTypes);
145 subdecks.Add(deck);
146 return deck;
147 }
148
171 public AnkiNote AddNote(AnkiNoteType noteType, IReadOnlyDictionary<string, string> fields, IEnumerable<string>? tags = null, string? guid = null, long? id = null)
172 {
173 ArgumentNullException.ThrowIfNull(noteType);
174 ArgumentNullException.ThrowIfNull(fields);
175 var note = new AnkiNote(noteType, fields, tags, id, guid);
176 note.GenerateCards(Id, nameof(fields));
177 notes.Add(note);
178 conventionalNoteTypes.Observe(noteType);
179 return note;
180 }
181
189 public bool RemoveNote(AnkiNote note) => notes.Remove(note);
190
191 internal void AddExistingSubdeck(AnkiDeck deck)
192 {
193 deck.UseConventionalNoteTypes(conventionalNoteTypes);
194 subdecks.Add(deck);
195 }
196
197 internal void AddExistingNote(AnkiNote note)
198 {
199 note.AttachToDeck(Id);
200 notes.Add(note);
201 conventionalNoteTypes.Observe(note.NoteType);
202 }
203
204 private void UseConventionalNoteTypes(ConventionalNoteTypeCache cache)
205 {
206 conventionalNoteTypes.CopyMissingTo(cache);
207 conventionalNoteTypes = cache;
208 foreach (var note in notes)
209 {
210 cache.Observe(note.NoteType);
211 }
212
213 foreach (var subdeck in subdecks)
214 {
215 subdeck.UseConventionalNoteTypes(cache);
216 }
217 }
218
225 public IEnumerable<AnkiDeck> Traverse()
226 {
227 yield return this;
228 foreach (var child in subdecks)
229 {
230 foreach (var descendant in child.Traverse())
231 {
232 yield return descendant;
233 }
234 }
235 }
236
262 string front,
263 string back,
264 IEnumerable<string>? tags = null,
265 string? guid = null,
266 long? id = null)
267 {
268 ArgumentNullException.ThrowIfNull(front);
269 ArgumentNullException.ThrowIfNull(back);
270
271 return AddNote(
272 conventionalNoteTypes.Basic ??= AnkiNoteTypes.CreateBasic(),
273 new Dictionary<string, string>(StringComparer.Ordinal)
274 {
275 ["Front"] = front,
276 ["Back"] = back,
277 },
278 tags,
279 guid,
280 id);
281 }
282
308 string front,
309 string back,
310 IEnumerable<string>? tags = null,
311 string? guid = null,
312 long? id = null)
313 {
314 ArgumentNullException.ThrowIfNull(front);
315 ArgumentNullException.ThrowIfNull(back);
316
317 return AddNote(
318 conventionalNoteTypes.BasicAndReversed ??= AnkiNoteTypes.CreateBasicAndReversed(),
319 new Dictionary<string, string>(StringComparer.Ordinal)
320 {
321 ["Front"] = front,
322 ["Back"] = back,
323 },
324 tags,
325 guid,
326 id);
327 }
328
365 string text,
366 string extra = "",
367 IEnumerable<string>? tags = null,
368 string? guid = null,
369 long? id = null)
370 {
371 ArgumentNullException.ThrowIfNull(text);
372 ArgumentNullException.ThrowIfNull(extra);
373 ValidateSimpleClozeText(text);
374
375 return AddNote(
376 conventionalNoteTypes.Cloze ??= AnkiNoteTypes.CreateCloze(),
377 new Dictionary<string, string>(StringComparer.Ordinal)
378 {
379 ["Text"] = text,
380 ["Extra"] = extra,
381 },
382 tags,
383 guid,
384 id);
385 }
386
387 private static void ValidateSimpleClozeText(string text)
388 {
389 const string marker = "{{c";
390 var searchIndex = 0;
391 var foundDeletion = false;
392
393 while (text.IndexOf(marker, searchIndex, StringComparison.Ordinal) is var markerIndex && markerIndex >= 0)
394 {
395 var indexStart = markerIndex + marker.Length;
396 if (indexStart >= text.Length || text[indexStart] is < '0' or > '9')
397 {
398 throw new ArgumentException(
399 "Cloze markers must place a positive numeric index after '{{c', such as '{{c1::answer}}'.",
400 nameof(text));
401 }
402
403 var contentSeparator = indexStart;
404 while (contentSeparator < text.Length && text[contentSeparator] is >= '0' and <= '9')
405 {
406 contentSeparator++;
407 }
408
409 if (contentSeparator + 1 >= text.Length
410 || text[contentSeparator] != ':'
411 || text[contentSeparator + 1] != ':')
412 {
413 throw new ArgumentException("Cloze indexes must be followed by the '::' content separator.", nameof(text));
414 }
415
416 var indexText = text.AsSpan(indexStart, contentSeparator - indexStart);
417 if (indexText[0] == '0'
418 || !int.TryParse(indexText, NumberStyles.None, CultureInfo.InvariantCulture, out var index)
419 || index < 1)
420 {
421 throw new ArgumentException("Cloze indexes must be positive integers representable by System.Int32.", nameof(text));
422 }
423
424 var contentStart = contentSeparator + 2;
425 var closingDelimiter = text.IndexOf("}}", contentStart, StringComparison.Ordinal);
426 if (closingDelimiter < 0)
427 {
428 throw new ArgumentException("Cloze deletions must end with '}}'.", nameof(text));
429 }
430
431 var content = text[contentStart..closingDelimiter];
432 if (content.Contains("{{", StringComparison.Ordinal))
433 {
434 throw new ArgumentException("Nested cloze or template markup requires the low-level AddNote API.", nameof(text));
435 }
436
437 var hintSeparator = content.IndexOf("::", StringComparison.Ordinal);
438 var answer = hintSeparator < 0 ? content : content[..hintSeparator];
439 if (answer.Length == 0)
440 {
441 throw new ArgumentException("Cloze answer text cannot be empty.", nameof(text));
442 }
443
444 if (hintSeparator >= 0
445 && content.IndexOf("::", hintSeparator + 2, StringComparison.Ordinal) >= 0)
446 {
447 throw new ArgumentException("Simple cloze markup can contain at most one hint separator.", nameof(text));
448 }
449
450 foundDeletion = true;
451 searchIndex = closingDelimiter + 2;
452 }
453
454 if (!foundDeletion)
455 {
456 throw new ArgumentException("Cloze text must contain at least one positive deletion such as '{{c1::answer}}'.", nameof(text));
457 }
458 }
459
460}
AnkiNote AddClozeNote(string text, string extra="", IEnumerable< string >? tags=null, string? guid=null, long? id=null)
Adds a conventional Cloze note and generates one card for each distinct positive cloze index.
Definition AnkiDeck.cs:364
IReadOnlyList< AnkiDeck > Subdecks
Gets only the direct children created below this deck.
Definition AnkiDeck.cs:115
AnkiDeck(string name, long? id=null)
Initializes a top-level deck with an empty note and subdeck collection.
Definition AnkiDeck.cs:50
IEnumerable< AnkiDeck > Traverse()
Enumerates the complete hierarchy in the same deterministic order used by writers.
Definition AnkiDeck.cs:225
AnkiMediaCollection Media
Gets media filenames and payloads contributed by this deck during package export.
Definition AnkiDeck.cs:111
long Id
Gets the persisted identity used by cards and package metadata to refer to this deck.
Definition AnkiDeck.cs:77
IReadOnlyList< AnkiNote > Notes
Gets notes assigned directly to this deck.
Definition AnkiDeck.cs:120
IDictionary< string, string > Metadata
Gets application-defined string metadata for AnkiIO native-JSON round trips.
Definition AnkiDeck.cs:96
AnkiNote AddBasicAndReversedNote(string front, string back, IEnumerable< string >? tags=null, string? guid=null, long? id=null)
Adds a conventional Basic (and reversed) note that generates front-to-back and back-to-front cards.
Definition AnkiDeck.cs:307
AnkiNote AddNote(AnkiNoteType noteType, IReadOnlyDictionary< string, string > fields, IEnumerable< string >? tags=null, string? guid=null, long? id=null)
Adds a note and generates its cards using safe new-card scheduling.
Definition AnkiDeck.cs:171
bool RemoveNote(AnkiNote note)
Removes a note and its generated cards from this deck.
Definition AnkiDeck.cs:189
AnkiDeck AddSubdeck(string name, long? id=null)
Creates and adds a direct child deck.
Definition AnkiDeck.cs:137
IDictionary< string, JsonElement > UnknownData
Gets unknown native-JSON deck properties retained without interpretation.
Definition AnkiDeck.cs:103
string Name
Gets this deck's local display-name segment, not its full hierarchy path.
Definition AnkiDeck.cs:81
string Description
Gets or sets the description Anki may show on the deck overview screen.
Definition AnkiDeck.cs:90
AnkiNote AddBasicNote(string front, string back, IEnumerable< string >? tags=null, string? guid=null, long? id=null)
Adds a conventional Basic note that generates one front-to-back card.
Definition AnkiDeck.cs:261
Owns media registrations for a deck and prevents unsafe or colliding names.
Defines the reusable schema and rendering rules shared by a family of Anki notes.
Creates fresh conventional Basic, reversed, and Cloze definitions for callers that need direct contro...
static AnkiNoteType CreateCloze()
Creates a conventional Cloze definition with Text and Extra fields.
static AnkiNoteType CreateBasicAndReversed()
Creates a conventional Basic (and reversed) definition that generates two study directions.
static AnkiNoteType CreateBasic()
Creates a conventional Basic definition that generates one front-to-back card.
Stores one fact or item of knowledge and owns the cards generated from it.
Definition AnkiNote.cs:27
AnkiNoteType NoteType
Gets the shared model that defines field order, rendering templates, CSS, and card generation.
Definition AnkiNote.cs:101