AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
LegacyCollectionDatabase.cs
1using System.Globalization;
2using System.Security.Cryptography;
3using System.Text;
4using System.Text.Json;
5using System.Text.Json.Nodes;
6using Microsoft.Data.Sqlite;
7
8namespace AnkiIO;
9
10internal static class LegacyCollectionDatabase
11{
12 private const char FieldSeparator = '\u001f';
13
14 public static async Task WriteAsync(string databasePath, IReadOnlyList<AnkiDeck> roots, CancellationToken cancellationToken)
15 {
16 await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = databasePath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }.ToString());
17 await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
18 await ExecuteAsync(connection, SchemaSql, cancellationToken).ConfigureAwait(false);
19 await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
20
21 var decks = roots.SelectMany(root => root.Traverse()).ToArray();
22 var names = BuildFullNames(roots);
23 var noteTypes = decks.SelectMany(deck => deck.Notes).Select(note => note.NoteType).GroupBy(type => type.Id).Select(group => group.First()).ToArray();
24 var nowMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
25 var collection = connection.CreateCommand();
26 collection.Transaction = (SqliteTransaction)transaction;
27 collection.CommandText = "INSERT INTO col VALUES (1,$crt,$mod,$scm,11,0,0,0,$conf,$models,$decks,$dconf,'{}')";
28 collection.Parameters.AddWithValue("$crt", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
29 collection.Parameters.AddWithValue("$mod", nowMilliseconds);
30 collection.Parameters.AddWithValue("$scm", nowMilliseconds);
31 collection.Parameters.AddWithValue("$conf", DefaultCollectionConfiguration);
32 collection.Parameters.AddWithValue("$models", BuildModelsJson(noteTypes, nowMilliseconds));
33 collection.Parameters.AddWithValue("$decks", BuildDecksJson(decks, names, nowMilliseconds));
34 collection.Parameters.AddWithValue("$dconf", DefaultDeckConfiguration);
35 await collection.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
36
37 foreach (var deck in decks)
38 {
39 foreach (var note in deck.Notes)
40 {
41 await InsertNoteAsync(connection, (SqliteTransaction)transaction, note, nowMilliseconds, cancellationToken).ConfigureAwait(false);
42 foreach (var card in note.Cards)
43 {
44 await InsertCardAsync(connection, (SqliteTransaction)transaction, card, nowMilliseconds, cancellationToken).ConfigureAwait(false);
45 foreach (var review in card.ReviewHistory)
46 {
47 await InsertReviewAsync(connection, (SqliteTransaction)transaction, card.Id, review, cancellationToken).ConfigureAwait(false);
48 }
49 }
50 }
51 }
52
53 await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
54 }
55
56 public static async Task<IReadOnlyList<AnkiDeck>> ReadAsync(string databasePath, CancellationToken cancellationToken)
57 {
58 await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = databasePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }.ToString());
59 await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
60 var col = connection.CreateCommand();
61 col.CommandText = "SELECT ver, models, decks FROM col LIMIT 1";
62 await using var reader = await col.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
63 if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
64 {
65 throw new InvalidDataException("The package collection has no col row.");
66 }
67
68 var version = reader.GetInt32(0);
69 if (version is not 11 and not 18)
70 {
71 throw new NotSupportedException($"Collection schema {version} is unsupported; this adapter supports legacy JSON metadata schema 11 packages.");
72 }
73
74 var modelsJson = reader.GetString(1);
75 var decksJson = reader.GetString(2);
76 if (string.IsNullOrWhiteSpace(modelsJson) || string.IsNullOrWhiteSpace(decksJson) || modelsJson[0] != '{' || decksJson[0] != '{')
77 {
78 throw new NotSupportedException("The collection uses protobuf metadata. Modern collection.anki21b reading requires a future schema-18 adapter.");
79 }
80
81 await reader.DisposeAsync().ConfigureAwait(false);
82 var types = ParseModels(modelsJson);
83 var (roots, deckById) = ParseDecks(decksJson);
84 var notes = new Dictionary<long, AnkiNote>();
85
86 var noteCommand = connection.CreateCommand();
87 noteCommand.CommandText = "SELECT id,guid,mid,tags,flds FROM notes ORDER BY id";
88 await using (var noteReader = await noteCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
89 {
90 while (await noteReader.ReadAsync(cancellationToken).ConfigureAwait(false))
91 {
92 var id = noteReader.GetInt64(0);
93 var typeId = noteReader.GetInt64(2);
94 if (!types.TryGetValue(typeId, out var type))
95 {
96 throw new InvalidDataException($"Note {id} references missing note type {typeId}.");
97 }
98
99 var values = noteReader.GetString(4).Split(FieldSeparator);
100 if (values.Length != type.Fields.Count)
101 {
102 throw new InvalidDataException($"Note {id} field count does not match note type {typeId}.");
103 }
104
105 var fields = type.Fields.Select((field, index) => (field.Name, values[index])).ToDictionary(pair => pair.Name, pair => pair.Item2, StringComparer.Ordinal);
106 var tags = noteReader.GetString(3).Split(' ', StringSplitOptions.RemoveEmptyEntries);
107 notes.Add(id, new AnkiNote(type, fields, tags, id, noteReader.GetString(1)));
108 }
109 }
110
111 var cardsByNote = new Dictionary<long, List<AnkiCard>>();
112 var cardCommand = connection.CreateCommand();
113 cardCommand.CommandText = "SELECT id,nid,did,ord,type,queue,due,ivl,factor,reps,lapses,left,odue,odid,flags,data FROM cards ORDER BY id";
114 await using (var cardReader = await cardCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
115 {
116 while (await cardReader.ReadAsync(cancellationToken).ConfigureAwait(false))
117 {
118 var noteId = cardReader.GetInt64(1);
119 var scheduling = new AnkiScheduling
120 {
121 Type = (AnkiCardType)cardReader.GetInt32(4),
122 Queue = (AnkiCardQueue)cardReader.GetInt32(5),
123 Due = cardReader.GetInt64(6),
124 Interval = cardReader.GetInt32(7),
125 EaseFactor = cardReader.GetInt32(8),
126 Repetitions = cardReader.GetInt32(9),
127 Lapses = cardReader.GetInt32(10),
128 RemainingSteps = cardReader.GetInt32(11),
129 OriginalDue = cardReader.GetInt64(12),
130 OriginalDeckId = cardReader.GetInt64(13),
131 CustomData = cardReader.GetString(15),
132 };
133 var card = new AnkiCard(cardReader.GetInt64(0), noteId, cardReader.GetInt64(2), cardReader.GetInt32(3), scheduling) { Flag = cardReader.GetInt32(14) & 7 };
134 cardsByNote.GetOrAdd(noteId).Add(card);
135 }
136 }
137
138 foreach (var pair in notes)
139 {
140 var cards = cardsByNote.GetValueOrDefault(pair.Key) ?? [];
141 pair.Value.RestoreCards(cards);
142 var targetDeckId = cards.FirstOrDefault()?.DeckId ?? deckById.Keys.First();
143 if (!deckById.TryGetValue(targetDeckId, out var target))
144 {
145 throw new InvalidDataException($"Note {pair.Key} card references missing deck {targetDeckId}.");
146 }
147
148 target.AddExistingNote(pair.Value);
149 }
150
151 return roots;
152 }
153
154 private static async Task InsertNoteAsync(SqliteConnection connection, SqliteTransaction transaction, AnkiNote note, long now, CancellationToken cancellationToken)
155 {
156 var values = string.Join(FieldSeparator, note.NoteType.Fields.Select(field => note.Fields[field.Name]));
157 var first = note.NoteType.Fields.Count == 0 ? string.Empty : note.Fields[note.NoteType.Fields[0].Name];
158#pragma warning disable CA5350 // Anki's legacy schema mandates the first 32 bits of SHA-1 as a lookup checksum, not for security.
159 var checksum = Convert.ToInt64(Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(first)))[..8], 16);
160#pragma warning restore CA5350
161 var command = connection.CreateCommand();
162 command.Transaction = transaction;
163 command.CommandText = "INSERT INTO notes VALUES ($id,$guid,$mid,$mod,-1,$tags,$flds,$sfld,$csum,0,'')";
164 command.Parameters.AddWithValue("$id", note.Id);
165 command.Parameters.AddWithValue("$guid", note.Guid);
166 command.Parameters.AddWithValue("$mid", note.NoteType.Id);
167 command.Parameters.AddWithValue("$mod", now / 1000);
168 command.Parameters.AddWithValue("$tags", note.Tags.Count == 0 ? string.Empty : " " + string.Join(' ', note.Tags) + " ");
169 command.Parameters.AddWithValue("$flds", values);
170 command.Parameters.AddWithValue("$sfld", first);
171 command.Parameters.AddWithValue("$csum", checksum);
172 await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
173 }
174
175 private static async Task InsertCardAsync(SqliteConnection connection, SqliteTransaction transaction, AnkiCard card, long now, CancellationToken cancellationToken)
176 {
177 var value = card.Scheduling;
178 var command = connection.CreateCommand();
179 command.Transaction = transaction;
180 command.CommandText = "INSERT INTO cards VALUES ($id,$nid,$did,$ord,$mod,-1,$type,$queue,$due,$ivl,$factor,$reps,$lapses,$left,$odue,$odid,$flags,$data)";
181 command.Parameters.AddWithValue("$id", card.Id);
182 command.Parameters.AddWithValue("$nid", card.NoteId);
183 command.Parameters.AddWithValue("$did", card.DeckId);
184 command.Parameters.AddWithValue("$ord", card.TemplateOrdinal);
185 command.Parameters.AddWithValue("$mod", now / 1000);
186 command.Parameters.AddWithValue("$type", (int)value.Type);
187 command.Parameters.AddWithValue("$queue", (int)value.Queue);
188 command.Parameters.AddWithValue("$due", value.Due);
189 command.Parameters.AddWithValue("$ivl", value.Interval);
190 command.Parameters.AddWithValue("$factor", value.EaseFactor);
191 command.Parameters.AddWithValue("$reps", value.Repetitions);
192 command.Parameters.AddWithValue("$lapses", value.Lapses);
193 command.Parameters.AddWithValue("$left", value.RemainingSteps);
194 command.Parameters.AddWithValue("$odue", value.OriginalDue);
195 command.Parameters.AddWithValue("$odid", value.OriginalDeckId);
196 command.Parameters.AddWithValue("$flags", card.Flag);
197 command.Parameters.AddWithValue("$data", value.CustomData);
198 await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
199 }
200
201 private static async Task InsertReviewAsync(SqliteConnection connection, SqliteTransaction transaction, long cardId, AnkiReviewLog review, CancellationToken cancellationToken)
202 {
203 var command = connection.CreateCommand();
204 command.Transaction = transaction;
205 command.CommandText = "INSERT INTO revlog VALUES ($id,$cid,-1,$ease,$ivl,$last,$factor,$time,$type)";
206 command.Parameters.AddWithValue("$id", review.Id);
207 command.Parameters.AddWithValue("$cid", cardId);
208 command.Parameters.AddWithValue("$ease", review.Ease);
209 command.Parameters.AddWithValue("$ivl", review.Interval);
210 command.Parameters.AddWithValue("$last", review.PreviousInterval);
211 command.Parameters.AddWithValue("$factor", review.EaseFactor);
212 command.Parameters.AddWithValue("$time", (long)review.AnswerTime.TotalMilliseconds);
213 command.Parameters.AddWithValue("$type", review.ReviewType);
214 await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
215 }
216
217 private static Dictionary<long, string> BuildFullNames(IEnumerable<AnkiDeck> roots)
218 {
219 var result = new Dictionary<long, string>();
220 void Walk(AnkiDeck deck, string prefix)
221 {
222 var name = prefix.Length == 0 ? deck.Name : prefix + "::" + deck.Name;
223 result.Add(deck.Id, name);
224 foreach (var child in deck.Subdecks)
225 {
226 Walk(child, name);
227 }
228 }
229
230 foreach (var root in roots)
231 {
232 Walk(root, string.Empty);
233 }
234
235 return result;
236 }
237
238 private static string BuildModelsJson(IEnumerable<AnkiNoteType> types, long now)
239 {
240 var root = new JsonObject();
241 foreach (var type in types.OrderBy(type => type.Id))
242 {
243 root[type.Id.ToString(CultureInfo.InvariantCulture)] = new JsonObject
244 {
245 ["id"] = type.Id,
246 ["name"] = type.Name,
247 ["type"] = (int)type.Kind,
248 ["mod"] = now / 1000,
249 ["usn"] = -1,
250 ["sortf"] = 0,
251 ["did"] = null,
252 ["css"] = type.Css,
253 ["latexPre"] = string.Empty,
254 ["latexPost"] = string.Empty,
255 ["latexsvg"] = false,
256 ["flds"] = new JsonArray(type.Fields.Select((field, index) => new JsonObject { ["name"] = field.Name, ["ord"] = index, ["sticky"] = field.IsSticky, ["rtl"] = field.IsRightToLeft, ["font"] = field.Font, ["size"] = field.FontSize, ["media"] = new JsonArray() }).ToArray()),
257 ["tmpls"] = new JsonArray(type.Templates.Select((template, index) => new JsonObject { ["name"] = template.Name, ["ord"] = index, ["qfmt"] = template.QuestionFormat, ["afmt"] = template.AnswerFormat, ["bqfmt"] = template.BrowserQuestionFormat ?? string.Empty, ["bafmt"] = template.BrowserAnswerFormat ?? string.Empty, ["did"] = null }).ToArray()),
258 ["req"] = new JsonArray(type.Templates.Select((_, index) => new JsonArray(index, "any", new JsonArray(0))).ToArray()),
259 };
260 }
261
262 return root.ToJsonString();
263 }
264
265 private static string BuildDecksJson(IEnumerable<AnkiDeck> decks, Dictionary<long, string> names, long now)
266 {
267 var root = new JsonObject();
268 foreach (var deck in decks.OrderBy(deck => deck.Id))
269 {
270 root[deck.Id.ToString(CultureInfo.InvariantCulture)] = new JsonObject { ["id"] = deck.Id, ["name"] = names[deck.Id], ["mod"] = now / 1000, ["usn"] = -1, ["desc"] = deck.Description, ["dyn"] = 0, ["conf"] = 1, ["collapsed"] = false, ["browserCollapsed"] = false, ["extendNew"] = 0, ["extendRev"] = 0, ["newToday"] = new JsonArray(0, 0), ["revToday"] = new JsonArray(0, 0), ["lrnToday"] = new JsonArray(0, 0), ["timeToday"] = new JsonArray(0, 0) };
271 }
272
273 return root.ToJsonString();
274 }
275
276 private static Dictionary<long, AnkiNoteType> ParseModels(string json)
277 {
278 var result = new Dictionary<long, AnkiNoteType>();
279 foreach (var pair in JsonNode.Parse(json)?.AsObject() ?? throw new InvalidDataException("Invalid models JSON."))
280 {
281 var value = pair.Value?.AsObject() ?? throw new InvalidDataException("Null note type.");
282 var id = value["id"]?.GetValue<long>() ?? long.Parse(pair.Key, CultureInfo.InvariantCulture);
283 var type = new AnkiNoteType(value["name"]?.GetValue<string>() ?? "Unnamed", (AnkiNoteTypeKind)(value["type"]?.GetValue<int>() ?? 0), id) { Css = value["css"]?.GetValue<string>() ?? string.Empty };
284 foreach (var fieldNode in value["flds"]?.AsArray() ?? [])
285 {
286 var field = fieldNode?.AsObject() ?? throw new InvalidDataException("Null field.");
287 type.AddConfiguredField(new AnkiField(
288 field["name"]?.GetValue<string>() ?? throw new InvalidDataException("Unnamed field."),
289 IsRightToLeft: field["rtl"]?.GetValue<bool>() ?? false,
290 IsSticky: field["sticky"]?.GetValue<bool>() ?? false,
291 Font: field["font"]?.GetValue<string>() ?? "Arial",
292 FontSize: field["size"]?.GetValue<int>() ?? 20));
293 }
294
295 foreach (var templateNode in value["tmpls"]?.AsArray() ?? [])
296 {
297 var template = templateNode?.AsObject() ?? throw new InvalidDataException("Null template.");
298 var browserQuestion = template["bqfmt"]?.GetValue<string>();
299 var browserAnswer = template["bafmt"]?.GetValue<string>();
300 type.AddConfiguredTemplate(new AnkiCardTemplate(
301 template["name"]?.GetValue<string>() ?? "Card",
302 template["qfmt"]?.GetValue<string>() ?? string.Empty,
303 template["afmt"]?.GetValue<string>() ?? string.Empty,
304 string.IsNullOrEmpty(browserQuestion) ? null : browserQuestion,
305 string.IsNullOrEmpty(browserAnswer) ? null : browserAnswer));
306 }
307
308 result.Add(id, type);
309 }
310
311 return result;
312 }
313
314 private static (IReadOnlyList<AnkiDeck> Roots, Dictionary<long, AnkiDeck> ById) ParseDecks(string json)
315 {
316 var values = (JsonNode.Parse(json)?.AsObject() ?? throw new InvalidDataException("Invalid decks JSON.")).Select(pair => pair.Value?.AsObject() ?? throw new InvalidDataException("Null deck.")).Select(value => new { Id = value["id"]!.GetValue<long>(), Name = value["name"]!.GetValue<string>(), Description = value["desc"]?.GetValue<string>() ?? string.Empty }).OrderBy(value => value.Name.Count(character => character == ':')).ThenBy(value => value.Name, StringComparer.Ordinal).ToArray();
317 var byName = new Dictionary<string, AnkiDeck>(StringComparer.Ordinal);
318 var byId = new Dictionary<long, AnkiDeck>();
319 var roots = new List<AnkiDeck>();
320 foreach (var value in values)
321 {
322 var parts = value.Name.Split("::", StringSplitOptions.None);
323 var deck = new AnkiDeck(parts[^1], value.Id) { Description = value.Description };
324 byName.Add(value.Name, deck);
325 byId.Add(value.Id, deck);
326 var parentName = string.Join("::", parts[..^1]);
327 if (parentName.Length == 0) roots.Add(deck); else if (byName.TryGetValue(parentName, out var parent)) parent.AddExistingSubdeck(deck); else throw new InvalidDataException($"Deck '{value.Name}' has no parent '{parentName}'.");
328 }
329
330 return (roots, byId);
331 }
332
333 private static async Task ExecuteAsync(SqliteConnection connection, string sql, CancellationToken cancellationToken)
334 {
335 var command = connection.CreateCommand();
336 command.CommandText = sql;
337 await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
338 }
339
340 private const string SchemaSql = """
341 PRAGMA journal_mode=DELETE;
342 CREATE TABLE col (id integer primary key, crt integer not null, mod integer not null, scm integer not null, ver integer not null, dty integer not null, usn integer not null, ls integer not null, conf text not null, models text not null, decks text not null, dconf text not null, tags text not null);
343 CREATE TABLE notes (id integer primary key, guid text not null, mid integer not null, mod integer not null, usn integer not null, tags text not null, flds text not null, sfld integer not null, csum integer not null, flags integer not null, data text not null);
344 CREATE TABLE cards (id integer primary key, nid integer not null, did integer not null, ord integer not null, mod integer not null, usn integer not null, type integer not null, queue integer not null, due integer not null, ivl integer not null, factor integer not null, reps integer not null, lapses integer not null, left integer not null, odue integer not null, odid integer not null, flags integer not null, data text not null);
345 CREATE TABLE revlog (id integer primary key, cid integer not null, usn integer not null, ease integer not null, ivl integer not null, lastIvl integer not null, factor integer not null, time integer not null, type integer not null);
346 CREATE TABLE graves (usn integer not null, oid integer not null, type integer not null);
347 CREATE INDEX ix_notes_usn ON notes (usn); CREATE INDEX ix_cards_usn ON cards (usn); CREATE INDEX ix_revlog_usn ON revlog (usn); CREATE INDEX ix_cards_nid ON cards (nid); CREATE INDEX ix_cards_sched ON cards (did, queue, due); CREATE INDEX ix_revlog_cid ON revlog (cid); CREATE INDEX ix_notes_csum ON notes (csum);
348 """;
349
350 private const string DefaultCollectionConfiguration = "{\"activeDecks\":[1],\"curDeck\":1,\"newSpread\":0,\"collapseTime\":1200,\"timeLim\":0,\"estTimes\":true,\"dueCounts\":true,\"curModel\":null,\"nextPos\":1,\"sortType\":\"noteFld\",\"sortBackwards\":false,\"addToCur\":true,\"dayLearnFirst\":false," + "\"schedVer\":2}";
351 private const string DefaultDeckConfiguration = "{\"1\":{\"id\":1,\"name\":\"Default\",\"mod\":0,\"usn\":0,\"maxTaken\":60,\"autoplay\":true,\"timer\":0,\"replayq\":true,\"new\":{\"delays\":[1,10],\"ints\":[1,4],\"initialFactor\":2500,\"separate\":true,\"order\":1,\"perDay\":20,\"bury\":true},\"rev\":{\"perDay\":200,\"ease4\":1.3,\"fuzz\":0.05,\"ivlFct\":1,\"maxIvl\":36500,\"bury\":true,\"hardFactor\":1.2},\"lapse\":{\"delays\":[10],\"mult\":0,\"minInt\":1,\"leechFails\":8,\"leechAction\":0}}}";
352
353 private static List<TValue> GetOrAdd<TKey, TValue>(this Dictionary<TKey, List<TValue>> dictionary, TKey key) where TKey : notnull
354 {
355 if (!dictionary.TryGetValue(key, out var list)) dictionary.Add(key, list = []);
356 return list;
357 }
358}
AnkiCardType
Identifies the learning phase retained by an Anki card.
AnkiCardQueue
Identifies the active, inactive, or preview queue in which Anki stores a card.
AnkiNoteTypeKind
Specifies how an AnkiNoteType turns one note into study cards.