AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiJsonSerializer.cs
1using System.Text.Json;
2using System.Text.Json.Serialization;
3
4namespace AnkiIO;
5
14public static class AnkiJsonSerializer
15{
16 private const string CurrentGeneratorName = "AnkiIO/1.0";
17
23 public const int CurrentFormatVersion = 1;
24
25 private static readonly JsonSerializerOptions Options = new()
26 {
27 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
28 WriteIndented = true,
29 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
30 };
31
44 public static string Serialize(AnkiDeck deck)
45 {
46 ArgumentNullException.ThrowIfNull(deck);
47 EnsureValid(deck);
48 return JsonSerializer.Serialize(ToDocument(deck), Options) + "\n";
49 }
50
68 public static AnkiDeck Deserialize(string json)
69 {
70 ArgumentNullException.ThrowIfNull(json);
71 var document = JsonSerializer.Deserialize<NativeDocument>(json, Options) ?? throw new JsonException("The JSON document was empty.");
72 return FromDocument(document);
73 }
74
91 public static async Task WriteAsync(AnkiDeck deck, Stream destination, CancellationToken cancellationToken = default)
92 {
93 ArgumentNullException.ThrowIfNull(destination);
94 EnsureValid(deck);
95 await JsonSerializer.SerializeAsync(destination, ToDocument(deck), Options, cancellationToken).ConfigureAwait(false);
96 }
97
120 public static async Task<AnkiDeck> ReadAsync(Stream source, CancellationToken cancellationToken = default)
121 {
122 ArgumentNullException.ThrowIfNull(source);
123 var document = await JsonSerializer.DeserializeAsync<NativeDocument>(source, Options, cancellationToken).ConfigureAwait(false) ?? throw new JsonException("The JSON document was empty.");
124 return FromDocument(document);
125 }
126
127 private static AnkiDeck FromDocument(NativeDocument document)
128 {
129 if (document.FormatVersion != CurrentFormatVersion)
130 {
131 throw new JsonException($"Unsupported AnkiIO JSON format version {document.FormatVersion}; expected {CurrentFormatVersion}.");
132 }
133
134 var serializedTypes = document.NoteTypes ?? throw new JsonException("The noteTypes collection cannot be null.");
135 var types = serializedTypes.ToDictionary(
136 value => (value ?? throw new JsonException("The noteTypes collection contains a null entry.")).Id,
137 value => FromDto(value!));
138 var deck = FromDto(document.Deck ?? throw new JsonException("The root deck is missing."), types);
139 EnsureValid(deck);
140 return deck;
141 }
142
143 private static NativeDocument ToDocument(AnkiDeck deck)
144 {
145 var types = deck.Traverse().SelectMany(value => value.Notes).Select(note => note.NoteType).GroupBy(type => type.Id).Select(group => group.First()).OrderBy(type => type.Id).Select(ToDto).ToList();
146 return new NativeDocument { FormatVersion = CurrentFormatVersion, Generator = CurrentGeneratorName, NoteTypes = types, Deck = ToDto(deck) };
147 }
148
149 private static NoteTypeDto ToDto(AnkiNoteType value) => new()
150 {
151 Id = value.Id,
152 Name = value.Name,
153 Kind = value.Kind,
154 Css = value.Css,
155 Fields = value.Fields.ToList(),
156 Templates = value.Templates.ToList(),
157 };
158
159 private static DeckDto ToDto(AnkiDeck value) => new()
160 {
161 Id = value.Id,
162 Name = value.Name,
163 Description = value.Description,
164 Metadata = new SortedDictionary<string, string>(value.Metadata, StringComparer.Ordinal),
165 Notes = value.Notes.OrderBy(note => note.Id).Select(ToDto).ToList(),
166 Subdecks = value.Subdecks.OrderBy(deck => deck.Name, StringComparer.Ordinal).ThenBy(deck => deck.Id).Select(ToDto).ToList(),
167 ExtensionData = value.UnknownData.Count == 0 ? null : new SortedDictionary<string, JsonElement>(value.UnknownData, StringComparer.Ordinal),
168 };
169
170 private static NoteDto ToDto(AnkiNote value) => new()
171 {
172 Id = value.Id,
173 Guid = value.Guid,
174 NoteTypeId = value.NoteType.Id,
175 Fields = value.NoteType.Fields.Select(field => value.Fields[field.Name]).ToList(),
176 Tags = value.Tags.Order(StringComparer.Ordinal).ToList(),
177 Cards = value.Cards.OrderBy(card => card.TemplateOrdinal).Select(card => new CardDto
178 {
179 Id = card.Id,
180 DeckId = card.DeckId,
181 TemplateOrdinal = card.TemplateOrdinal,
182 Flag = card.Flag,
183 Scheduling = card.Scheduling,
184 ReviewHistory = card.ReviewHistory.OrderBy(review => review.Id).ToList(),
185 }).ToList(),
186 };
187
188 private static AnkiNoteType FromDto(NoteTypeDto value)
189 {
190 var type = new AnkiNoteType(value.Name, value.Kind, value.Id) { Css = value.Css };
191 foreach (var field in value.Fields ?? throw new JsonException($"Note type {value.Id} has a null fields collection."))
192 {
193 type.AddConfiguredField(field ?? throw new JsonException($"Note type {value.Id} contains a null field definition."));
194 }
195
196 foreach (var template in value.Templates ?? throw new JsonException($"Note type {value.Id} has a null templates collection."))
197 {
198 type.AddConfiguredTemplate(template ?? throw new JsonException($"Note type {value.Id} contains a null template definition."));
199 }
200
201 return type;
202 }
203
204 private static AnkiDeck FromDto(DeckDto value, IReadOnlyDictionary<long, AnkiNoteType> noteTypes)
205 {
206 var deck = new AnkiDeck(value.Name, value.Id) { Description = value.Description };
207 foreach (var pair in value.Metadata ?? throw new JsonException($"Deck {value.Id} has a null metadata object."))
208 {
209 deck.Metadata.Add(pair.Key, pair.Value ?? throw new JsonException($"Deck {value.Id} metadata '{pair.Key}' has a null value."));
210 }
211
212 if (value.ExtensionData is not null)
213 {
214 foreach (var pair in value.ExtensionData)
215 {
216 deck.UnknownData[pair.Key] = pair.Value.Clone();
217 }
218 }
219
220 foreach (var valueNote in value.Notes ?? throw new JsonException($"Deck {value.Id} has a null notes collection."))
221 {
222 if (valueNote is null)
223 {
224 throw new JsonException($"Deck {value.Id} contains a null note.");
225 }
226
227 if (!noteTypes.TryGetValue(valueNote.NoteTypeId, out var type))
228 {
229 throw new JsonException($"Note {valueNote.Id} references missing note type {valueNote.NoteTypeId}.");
230 }
231
232 var serializedFields = valueNote.Fields ?? throw new JsonException($"Note {valueNote.Id} has a null fields collection.");
233 if (serializedFields.Count != type.Fields.Count)
234 {
235 throw new JsonException($"Note {valueNote.Id} has {serializedFields.Count} fields; note type {type.Id} requires {type.Fields.Count}.");
236 }
237
238 var fields = type.Fields.Select((field, index) => (field.Name, Value: serializedFields[index] ?? throw new JsonException($"Note {valueNote.Id} contains a null value for field '{field.Name}'."))).ToDictionary(pair => pair.Name, pair => pair.Value, StringComparer.Ordinal);
239 var tags = valueNote.Tags ?? throw new JsonException($"Note {valueNote.Id} has a null tags collection.");
240 if (tags.Any(tag => tag is null))
241 {
242 throw new JsonException($"Note {valueNote.Id} contains a null tag.");
243 }
244
245 var note = deck.AddNote(type, fields, tags, valueNote.Guid, valueNote.Id);
246 var cards = valueNote.Cards ?? throw new JsonException($"Note {valueNote.Id} has a null cards collection.");
247 note.RestoreCards(cards.Select(cardValue =>
248 {
249 var card = cardValue ?? throw new JsonException($"Note {valueNote.Id} contains a null card.");
250 var scheduling = card.Scheduling ?? throw new JsonException($"Card {card.Id} has a null scheduling object.");
251 var restored = new AnkiCard(card.Id, valueNote.Id, card.DeckId, card.TemplateOrdinal, scheduling) { Flag = card.Flag };
252 foreach (var review in card.ReviewHistory ?? throw new JsonException($"Card {card.Id} has a null reviewHistory collection."))
253 {
254 restored.ReviewHistory.Add(review ?? throw new JsonException($"Card {card.Id} contains a null review-history entry."));
255 }
256
257 return restored;
258 }));
259 }
260
261 foreach (var child in value.Subdecks ?? throw new JsonException($"Deck {value.Id} has a null subdecks collection."))
262 {
263 deck.AddExistingSubdeck(FromDto(child ?? throw new JsonException($"Deck {value.Id} contains a null subdeck."), noteTypes));
264 }
265
266 return deck;
267 }
268
269 private static void EnsureValid(AnkiDeck deck)
270 {
271 var result = AnkiValidator.Validate(deck);
272 if (!result.IsValid)
273 {
274 throw new AnkiValidationException(result);
275 }
276 }
277
278}
Builds one named deck hierarchy and acts as the root for validation and export.
Definition AnkiDeck.cs:31
Serializes complete deck hierarchies using AnkiIO's deterministic, versioned native JSON format.
static async Task WriteAsync(AnkiDeck deck, Stream destination, CancellationToken cancellationToken=default)
Asynchronously writes a validated hierarchy as UTF-8 native JSON.
static async Task< AnkiDeck > ReadAsync(Stream source, CancellationToken cancellationToken=default)
Asynchronously reads and validates a native JSON hierarchy from a UTF-8 stream.
static AnkiDeck Deserialize(string json)
Deserializes an AnkiIO native JSON document and validates the reconstructed hierarchy.
static string Serialize(AnkiDeck deck)
Serializes a validated deck hierarchy to a culture-invariant JSON string.
const int CurrentFormatVersion
Identifies the native JSON schema emitted and accepted by this release.
Stops serialization or package creation when a deck has structured validation errors.
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.