1using System.Globalization;
2using System.Security.Cryptography;
5using System.Text.Json.Nodes;
6using Microsoft.Data.Sqlite;
10internal static class LegacyCollectionDatabase
12 private const char FieldSeparator =
'\u001f';
14 public static async Task WriteAsync(
string databasePath, IReadOnlyList<AnkiDeck> roots, CancellationToken cancellationToken)
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);
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);
37 foreach (var deck
in decks)
39 foreach (var note
in deck.Notes)
41 await InsertNoteAsync(connection, (SqliteTransaction)transaction, note, nowMilliseconds, cancellationToken).ConfigureAwait(
false);
42 foreach (var card
in note.Cards)
44 await InsertCardAsync(connection, (SqliteTransaction)transaction, card, nowMilliseconds, cancellationToken).ConfigureAwait(
false);
45 foreach (var review
in card.ReviewHistory)
47 await InsertReviewAsync(connection, (SqliteTransaction)transaction, card.Id, review, cancellationToken).ConfigureAwait(
false);
53 await transaction.CommitAsync(cancellationToken).ConfigureAwait(
false);
56 public static async Task<IReadOnlyList<AnkiDeck>> ReadAsync(
string databasePath, CancellationToken cancellationToken)
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))
65 throw new InvalidDataException(
"The package collection has no col row.");
68 var version = reader.GetInt32(0);
69 if (version is not 11 and not 18)
71 throw new NotSupportedException($
"Collection schema {version} is unsupported; this adapter supports legacy JSON metadata schema 11 packages.");
74 var modelsJson = reader.GetString(1);
75 var decksJson = reader.GetString(2);
76 if (
string.IsNullOrWhiteSpace(modelsJson) ||
string.IsNullOrWhiteSpace(decksJson) || modelsJson[0] !=
'{' || decksJson[0] !=
'{')
78 throw new NotSupportedException(
"The collection uses protobuf metadata. Modern collection.anki21b reading requires a future schema-18 adapter.");
81 await reader.DisposeAsync().ConfigureAwait(
false);
82 var types = ParseModels(modelsJson);
83 var (roots, deckById) = ParseDecks(decksJson);
84 var notes =
new Dictionary<long, AnkiNote>();
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))
90 while (await noteReader.ReadAsync(cancellationToken).ConfigureAwait(
false))
92 var
id = noteReader.GetInt64(0);
93 var typeId = noteReader.GetInt64(2);
94 if (!types.TryGetValue(typeId, out var type))
96 throw new InvalidDataException($
"Note {id} references missing note type {typeId}.");
99 var values = noteReader.GetString(4).Split(FieldSeparator);
100 if (values.Length != type.Fields.Count)
102 throw new InvalidDataException($
"Note {id} field count does not match note type {typeId}.");
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)));
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))
116 while (await cardReader.ReadAsync(cancellationToken).ConfigureAwait(
false))
118 var noteId = cardReader.GetInt64(1);
119 var scheduling =
new AnkiScheduling
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),
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);
138 foreach (var pair
in notes)
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))
145 throw new InvalidDataException($
"Note {pair.Key} card references missing deck {targetDeckId}.");
148 target.AddExistingNote(pair.Value);
154 private static async Task InsertNoteAsync(SqliteConnection connection, SqliteTransaction transaction, AnkiNote note,
long now, CancellationToken cancellationToken)
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
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);
175 private static async Task InsertCardAsync(SqliteConnection connection, SqliteTransaction transaction, AnkiCard card,
long now, CancellationToken cancellationToken)
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);
201 private static async Task InsertReviewAsync(SqliteConnection connection, SqliteTransaction transaction,
long cardId, AnkiReviewLog review, CancellationToken cancellationToken)
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);
217 private static Dictionary<long, string> BuildFullNames(IEnumerable<AnkiDeck> roots)
219 var result =
new Dictionary<long, string>();
220 void Walk(AnkiDeck deck,
string prefix)
222 var name = prefix.Length == 0 ? deck.Name : prefix +
"::" + deck.Name;
223 result.Add(deck.Id, name);
224 foreach (var child
in deck.Subdecks)
230 foreach (var root
in roots)
232 Walk(root,
string.Empty);
238 private static string BuildModelsJson(IEnumerable<AnkiNoteType> types,
long now)
240 var root =
new JsonObject();
241 foreach (var type
in types.OrderBy(type => type.Id))
243 root[type.Id.ToString(CultureInfo.InvariantCulture)] =
new JsonObject
246 [
"name"] = type.Name,
247 [
"type"] = (int)type.Kind,
248 [
"mod"] = now / 1000,
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()),
262 return root.ToJsonString();
265 private static string BuildDecksJson(IEnumerable<AnkiDeck> decks, Dictionary<long, string> names,
long now)
267 var root =
new JsonObject();
268 foreach (var deck
in decks.OrderBy(deck => deck.Id))
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) };
273 return root.ToJsonString();
276 private static Dictionary<long, AnkiNoteType> ParseModels(
string json)
278 var result =
new Dictionary<long, AnkiNoteType>();
279 foreach (var pair
in JsonNode.Parse(json)?.AsObject() ??
throw new InvalidDataException(
"Invalid models JSON."))
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() ?? [])
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));
295 foreach (var templateNode
in value[
"tmpls"]?.AsArray() ?? [])
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));
308 result.Add(
id, type);
314 private static (IReadOnlyList<AnkiDeck> Roots, Dictionary<long, AnkiDeck> ById) ParseDecks(
string json)
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)
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}'.");
330 return (roots, byId);
333 private static async Task ExecuteAsync(SqliteConnection connection,
string sql, CancellationToken cancellationToken)
335 var command = connection.CreateCommand();
336 command.CommandText = sql;
337 await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(
false);
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);
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}}}";
353 private static List<TValue> GetOrAdd<TKey, TValue>(
this Dictionary<TKey, List<TValue>> dictionary, TKey key) where TKey : notnull
355 if (!dictionary.TryGetValue(key, out var list)) dictionary.Add(key, list = []);
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.