19 private static readonly JsonSerializerOptions Options =
new() { WriteIndented =
true };
41 ArgumentNullException.ThrowIfNull(root);
43 if (!validation.IsValid)
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";
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"))
85 var model = RequiredObject(node,
"A note_models entry must be an object.");
86 var uuid = RequiredString(model,
"crowdanki_uuid");
90 Css = OptionalString(model,
"css") ??
string.Empty,
92 foreach (var fieldNode
in OptionalArray(model,
"flds"))
94 var field = RequiredObject(fieldNode,
"A field entry must be an object.");
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)));
103 foreach (var templateNode
in OptionalArray(model,
"tmpls"))
105 var
template = RequiredObject(templateNode,
"A template entry must be an object.");
106 var browserQuestion = OptionalString(
template,
"bqfmt");
107 var browserAnswer = OptionalString(
template,
"bafmt");
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));
116 types.Add(uuid, type);
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."));
124 private static JsonObject ExportDeck(
AnkiDeck deck, IReadOnlyDictionary<long, string> typeUuids,
bool isRoot)
126 var result =
new JsonObject
128 [
"__type__"] =
"Deck",
129 [
"crowdanki_uuid"] = StableUuid(
"deck", deck.
Id),
130 [
"name"] = deck.
Name,
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()),
139 result.Remove(
"desc");
145 private static JsonObject ExportNote(AnkiNote note,
string typeUuid) =>
new()
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()),
153 [
"data"] =
string.Empty,
156 private static JsonObject ExportNoteType(AnkiNoteType type,
string uuid) =>
new()
158 [
"__type__"] =
"NoteModel",
159 [
"crowdanki_uuid"] = uuid,
160 [
"name"] = type.Name,
161 [
"type"] = type.Kind == AnkiNoteTypeKind.Cloze ? 1 : 0,
163 [
"flds"] =
new JsonArray(type.Fields.Select((field, index) =>
new JsonObject
165 [
"name"] = field.Name,
167 [
"font"] = field.Font,
168 [
"size"] = field.FontSize,
169 [
"rtl"] = field.IsRightToLeft,
170 [
"sticky"] = field.IsSticky,
171 [
"media"] = new JsonArray(),
173 [
"tmpls"] =
new JsonArray(type.Templates.Select((
template, index) =>
new JsonObject
175 [
"name"] = template.Name,
177 [
"qfmt"] = template.QuestionFormat,
178 [
"afmt"] = template.AnswerFormat,
179 [
"bqfmt"] = template.BrowserQuestionFormat ?? string.Empty,
180 [
"bafmt"] = template.BrowserAnswerFormat ?? string.Empty,
184 private static AnkiDeck ImportDeck(JsonObject value, IReadOnlyDictionary<string, AnkiNoteType> types, List<AnkiDiagnostic> diagnostics,
string location)
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))
189 Description = OptionalString(value,
"desc") ??
string.Empty,
191 foreach (var noteNode
in OptionalArray(value,
"notes"))
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))
197 throw new JsonException($
"Note references unknown note_model_uuid '{typeUuid}'.");
200 var values = OptionalArray(note,
"fields").Select((field, index) => OptionalString(field, $
"fields[{index}]") ??
string.Empty).ToArray();
201 if (values.Length != type.Fields.Count)
203 throw new JsonException($
"Note '{note["guid
"]}' has {values.Length} fields; model '{type.Name}' requires {type.Fields.Count}.");
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));
212 if (OptionalArray(value,
"media_files").Count > 0)
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."));
217 foreach (var childNode
in OptionalArray(value,
"children"))
219 deck.AddExistingSubdeck(ImportDeck(RequiredObject(childNode,
"A child deck entry must be an object."), types, diagnostics, location +
".children"));
225 private static JsonArray OptionalArray(JsonObject value,
string name)
227 var node = value[name];
231 JsonArray array => array,
232 _ =>
throw new JsonException($
"Property '{name}' must be an array."),
236 private static JsonObject RequiredObject(JsonNode? value,
string message) => value as JsonObject ??
throw new JsonException(message);
238 private static T OptionalValue<T>(JsonObject value,
string name, T fallback)
240 var node = value[name];
246 if (node is JsonValue scalar && scalar.TryGetValue<T>(out var result))
251 throw new JsonException($
"Property '{name}' has an incompatible JSON type.");
254 private static string? OptionalString(JsonObject value,
string name) => OptionalString(value[name], name);
256 private static string? OptionalString(JsonNode? value,
string name)
263 if (value is JsonValue scalar && scalar.TryGetValue<
string>(out var result))
268 throw new JsonException($
"Property '{name}' must be a string.");
271 private static string RequiredString(JsonObject value,
string name) =>
272 OptionalString(value, name) is { } result && !
string.IsNullOrWhiteSpace(result)
274 :
throw new JsonException($
"Required property '{name}' is missing or blank.");
276 private static string StableUuid(
string scope,
long value)
278 var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(scope +
":" + value.ToString(System.Globalization.CultureInfo.InvariantCulture)));
279 return new Guid(bytes.AsSpan(0, 16)).ToString();