AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
CrowdAnkiJson.cs
1using System.Security.Cryptography;
2using System.Text;
3using System.Text.Json;
4using System.Text.Json.Nodes;
5
6namespace AnkiIO;
7
17public static class CrowdAnkiJson
18{
19 private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
20
39 public static string Export(AnkiDeck root)
40 {
41 ArgumentNullException.ThrowIfNull(root);
42 var validation = AnkiValidator.Validate(root);
43 if (!validation.IsValid)
44 {
45 throw new AnkiValidationException(validation);
46 }
47
48 var types = root.Traverse().SelectMany(deck => deck.Notes).Select(note => note.NoteType).GroupBy(type => type.Id).Select(group => group.First()).OrderBy(type => type.Id).ToArray();
49 var typeUuids = types.ToDictionary(type => type.Id, type => StableUuid("note-type", type.Id));
50 var json = ExportDeck(root, typeUuids, isRoot: true);
51 json["note_models"] = new JsonArray(types.Select(type => ExportNoteType(type, typeUuids[type.Id])).ToArray());
52 json["deck_configurations"] = new JsonArray();
53 return json.ToJsonString(Options) + "\n";
54 }
55
77 public static CrowdAnkiImportResult Import(string json)
78 {
79 ArgumentNullException.ThrowIfNull(json);
80 var root = JsonNode.Parse(json) as JsonObject ?? throw new JsonException("Expected a CrowdAnki deck object.");
81 var diagnostics = new List<AnkiDiagnostic>();
82 var types = new Dictionary<string, AnkiNoteType>(StringComparer.Ordinal);
83 foreach (var node in OptionalArray(root, "note_models"))
84 {
85 var model = RequiredObject(node, "A note_models entry must be an object.");
86 var uuid = RequiredString(model, "crowdanki_uuid");
87 var kind = OptionalValue(model, "type", 0) == 1 ? AnkiNoteTypeKind.Cloze : AnkiNoteTypeKind.Standard;
88 var type = new AnkiNoteType(RequiredString(model, "name"), kind, AnkiId.FromStableValue("crowdanki-note-type", uuid))
89 {
90 Css = OptionalString(model, "css") ?? string.Empty,
91 };
92 foreach (var fieldNode in OptionalArray(model, "flds"))
93 {
94 var field = RequiredObject(fieldNode, "A field entry must be an object.");
95 type.AddConfiguredField(new AnkiField(
96 RequiredString(field, "name"),
97 IsRightToLeft: OptionalValue(field, "rtl", false),
98 IsSticky: OptionalValue(field, "sticky", false),
99 Font: OptionalString(field, "font") ?? "Arial",
100 FontSize: OptionalValue(field, "size", 20)));
101 }
102
103 foreach (var templateNode in OptionalArray(model, "tmpls"))
104 {
105 var template = RequiredObject(templateNode, "A template entry must be an object.");
106 var browserQuestion = OptionalString(template, "bqfmt");
107 var browserAnswer = OptionalString(template, "bafmt");
108 type.AddConfiguredTemplate(new AnkiCardTemplate(
109 RequiredString(template, "name"),
110 OptionalString(template, "qfmt") ?? string.Empty,
111 OptionalString(template, "afmt") ?? string.Empty,
112 string.IsNullOrEmpty(browserQuestion) ? null : browserQuestion,
113 string.IsNullOrEmpty(browserAnswer) ? null : browserAnswer));
114 }
115
116 types.Add(uuid, type);
117 }
118
119 var deck = ImportDeck(root, types, diagnostics, "$");
120 diagnostics.Add(new(AnkiDiagnosticSeverity.Information, "CROWD001", "CrowdAnki JSON does not carry portable card scheduling or review history; imported cards use safe new-card scheduling.", SuggestedRemediation: "Use native JSON or APKG when scheduling must be preserved."));
121 return new CrowdAnkiImportResult(deck, Array.AsReadOnly(diagnostics.ToArray()));
122 }
123
124 private static JsonObject ExportDeck(AnkiDeck deck, IReadOnlyDictionary<long, string> typeUuids, bool isRoot)
125 {
126 var result = new JsonObject
127 {
128 ["__type__"] = "Deck",
129 ["crowdanki_uuid"] = StableUuid("deck", deck.Id),
130 ["name"] = deck.Name,
131 ["desc"] = deck.Description,
132 ["deck_config_uuid"] = StableUuid("deck-config", 1),
133 ["media_files"] = new JsonArray(deck.Media.Files.Select(media => JsonValue.Create(media.FileName)).ToArray()),
134 ["notes"] = new JsonArray(deck.Notes.OrderBy(note => note.Guid, StringComparer.Ordinal).Select(note => ExportNote(note, typeUuids[note.NoteType.Id])).ToArray()),
135 ["children"] = new JsonArray(deck.Subdecks.OrderBy(child => child.Name, StringComparer.Ordinal).Select(child => ExportDeck(child, typeUuids, isRoot: false)).ToArray()),
136 };
137 if (!isRoot)
138 {
139 result.Remove("desc");
140 }
141
142 return result;
143 }
144
145 private static JsonObject ExportNote(AnkiNote note, string typeUuid) => new()
146 {
147 ["__type__"] = "Note",
148 ["guid"] = note.Guid,
149 ["note_model_uuid"] = typeUuid,
150 ["fields"] = new JsonArray(note.NoteType.Fields.Select(field => JsonValue.Create(note.Fields[field.Name])).ToArray()),
151 ["tags"] = new JsonArray(note.Tags.Order(StringComparer.Ordinal).Select(tag => JsonValue.Create(tag)).ToArray()),
152 ["flags"] = 0,
153 ["data"] = string.Empty,
154 };
155
156 private static JsonObject ExportNoteType(AnkiNoteType type, string uuid) => new()
157 {
158 ["__type__"] = "NoteModel",
159 ["crowdanki_uuid"] = uuid,
160 ["name"] = type.Name,
161 ["type"] = type.Kind == AnkiNoteTypeKind.Cloze ? 1 : 0,
162 ["css"] = type.Css,
163 ["flds"] = new JsonArray(type.Fields.Select((field, index) => new JsonObject
164 {
165 ["name"] = field.Name,
166 ["ord"] = index,
167 ["font"] = field.Font,
168 ["size"] = field.FontSize,
169 ["rtl"] = field.IsRightToLeft,
170 ["sticky"] = field.IsSticky,
171 ["media"] = new JsonArray(),
172 }).ToArray()),
173 ["tmpls"] = new JsonArray(type.Templates.Select((template, index) => new JsonObject
174 {
175 ["name"] = template.Name,
176 ["ord"] = index,
177 ["qfmt"] = template.QuestionFormat,
178 ["afmt"] = template.AnswerFormat,
179 ["bqfmt"] = template.BrowserQuestionFormat ?? string.Empty,
180 ["bafmt"] = template.BrowserAnswerFormat ?? string.Empty,
181 }).ToArray()),
182 };
183
184 private static AnkiDeck ImportDeck(JsonObject value, IReadOnlyDictionary<string, AnkiNoteType> types, List<AnkiDiagnostic> diagnostics, string location)
185 {
186 var uuid = OptionalString(value, "crowdanki_uuid") ?? StableUuid("anonymous-deck", AnkiId.New());
187 var deck = new AnkiDeck(RequiredString(value, "name"), AnkiId.FromStableValue("crowdanki-deck", uuid))
188 {
189 Description = OptionalString(value, "desc") ?? string.Empty,
190 };
191 foreach (var noteNode in OptionalArray(value, "notes"))
192 {
193 var note = RequiredObject(noteNode, "A note entry must be an object.");
194 var typeUuid = RequiredString(note, "note_model_uuid");
195 if (!types.TryGetValue(typeUuid, out var type))
196 {
197 throw new JsonException($"Note references unknown note_model_uuid '{typeUuid}'.");
198 }
199
200 var values = OptionalArray(note, "fields").Select((field, index) => OptionalString(field, $"fields[{index}]") ?? string.Empty).ToArray();
201 if (values.Length != type.Fields.Count)
202 {
203 throw new JsonException($"Note '{note["guid"]}' has {values.Length} fields; model '{type.Name}' requires {type.Fields.Count}.");
204 }
205
206 var fields = type.Fields.Select((field, index) => (field.Name, Value: values[index])).ToDictionary(pair => pair.Name, pair => pair.Value, StringComparer.Ordinal);
207 var tags = OptionalArray(note, "tags").Select((tag, index) => OptionalString(tag, $"tags[{index}]") ?? string.Empty).Where(tag => tag.Length > 0);
208 var guid = RequiredString(note, "guid");
209 deck.AddNote(type, fields, tags, guid, AnkiId.FromStableValue("crowdanki-note", guid));
210 }
211
212 if (OptionalArray(value, "media_files").Count > 0)
213 {
214 diagnostics.Add(new(AnkiDiagnosticSeverity.Warning, "CROWD002", "Media filenames were found, but CrowdAnki JSON stores payloads as sibling files and this string-only import cannot resolve them.", Location: location, DeckId: deck.Id, SuggestedRemediation: "Register media files from the CrowdAnki directory before package export."));
215 }
216
217 foreach (var childNode in OptionalArray(value, "children"))
218 {
219 deck.AddExistingSubdeck(ImportDeck(RequiredObject(childNode, "A child deck entry must be an object."), types, diagnostics, location + ".children"));
220 }
221
222 return deck;
223 }
224
225 private static JsonArray OptionalArray(JsonObject value, string name)
226 {
227 var node = value[name];
228 return node switch
229 {
230 null => [],
231 JsonArray array => array,
232 _ => throw new JsonException($"Property '{name}' must be an array."),
233 };
234 }
235
236 private static JsonObject RequiredObject(JsonNode? value, string message) => value as JsonObject ?? throw new JsonException(message);
237
238 private static T OptionalValue<T>(JsonObject value, string name, T fallback)
239 {
240 var node = value[name];
241 if (node is null)
242 {
243 return fallback;
244 }
245
246 if (node is JsonValue scalar && scalar.TryGetValue<T>(out var result))
247 {
248 return result;
249 }
250
251 throw new JsonException($"Property '{name}' has an incompatible JSON type.");
252 }
253
254 private static string? OptionalString(JsonObject value, string name) => OptionalString(value[name], name);
255
256 private static string? OptionalString(JsonNode? value, string name)
257 {
258 if (value is null)
259 {
260 return null;
261 }
262
263 if (value is JsonValue scalar && scalar.TryGetValue<string>(out var result))
264 {
265 return result;
266 }
267
268 throw new JsonException($"Property '{name}' must be a string.");
269 }
270
271 private static string RequiredString(JsonObject value, string name) =>
272 OptionalString(value, name) is { } result && !string.IsNullOrWhiteSpace(result)
273 ? result
274 : throw new JsonException($"Required property '{name}' is missing or blank.");
275
276 private static string StableUuid(string scope, long value)
277 {
278 var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(scope + ":" + value.ToString(System.Globalization.CultureInfo.InvariantCulture)));
279 return new Guid(bytes.AsSpan(0, 16)).ToString();
280 }
281}
Defines one study direction by mapping note fields to a card front and back.
Builds one named deck hierarchy and acts as the root for validation and export.
Definition AnkiDeck.cs:31
IReadOnlyList< AnkiDeck > Subdecks
Gets only the direct children created below this deck.
Definition AnkiDeck.cs:115
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
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
Configures one named input field in an AnkiNoteType.
Definition AnkiField.cs:45
Creates positive 64-bit identifiers for new objects or repeatable external imports.
Definition AnkiId.cs:15
static long FromStableValue(string scope, string value)
Derives a deterministic positive identifier from a caller-controlled namespace and stable value.
Definition AnkiId.cs:57
IReadOnlyCollection< AnkiMediaFile > Files
Gets a snapshot of registered files in deterministic filename order.
Defines the reusable schema and rendering rules shared by a family of Anki notes.
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.
Returns a CrowdAnki-style import together with explicit information about concepts that could not be ...
Imports and exports a conservative, independently implemented subset of CrowdAnki-style JSON.
static CrowdAnkiImportResult Import(string json)
Imports the supported CrowdAnki-style JSON subset and reports lossy concepts as structured diagnostic...
static string Export(AnkiDeck root)
Exports a validated deck hierarchy using the supported CrowdAnki-style JSON concepts.
AnkiDiagnosticSeverity
Classifies whether a diagnostic is explanatory, lossy, or blocks a validated write.
AnkiNoteTypeKind
Specifies how an AnkiNoteType turns one note into study cards.
@ Cloze
Generates one card for every distinct positive {{cN::...}} index in the Text field.