AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiPackageWriter.cs
1using System.IO.Compression;
2using System.Security.Cryptography;
3using System.Text.Json;
4
5namespace AnkiIO;
6
48public static class AnkiPackageWriter
49{
75 public static async Task WriteAsync(AnkiDeck deck, string path, CancellationToken cancellationToken = default)
76 {
77 ArgumentNullException.ThrowIfNull(deck);
78 ArgumentException.ThrowIfNullOrWhiteSpace(path);
79 var plan = CreateWritePlan([deck], packageMedia: null);
80 await WritePathAsync(plan, path, cancellationToken).ConfigureAwait(false);
81 }
82
109 public static async Task WriteAsync(AnkiPackage package, string path, CancellationToken cancellationToken = default)
110 {
111 ArgumentNullException.ThrowIfNull(package);
112 ArgumentException.ThrowIfNullOrWhiteSpace(path);
113 var plan = CreatePackageWritePlan(package);
114 await WritePathAsync(plan, path, cancellationToken).ConfigureAwait(false);
115 }
116
143 public static async Task WriteAsync(AnkiDeck deck, Stream destination, CancellationToken cancellationToken = default)
144 {
145 ArgumentNullException.ThrowIfNull(deck);
146 ArgumentNullException.ThrowIfNull(destination);
147 EnsureWritable(destination);
148 var plan = CreateWritePlan([deck], packageMedia: null);
149 await WriteArchiveAsync(plan, destination, cancellationToken).ConfigureAwait(false);
150 }
151
178 public static async Task WriteAsync(AnkiPackage package, Stream destination, CancellationToken cancellationToken = default)
179 {
180 ArgumentNullException.ThrowIfNull(package);
181 ArgumentNullException.ThrowIfNull(destination);
182 EnsureWritable(destination);
183 var plan = CreatePackageWritePlan(package);
184 await WriteArchiveAsync(plan, destination, cancellationToken).ConfigureAwait(false);
185 }
186
187 private static AnkiPackageWritePlan CreatePackageWritePlan(AnkiPackage package)
188 {
189 var roots = package.Decks.ToArray();
190 if (roots.Length == 0)
191 {
192 throw new ArgumentException("A package must contain at least one top-level deck.", nameof(package));
193 }
194
195 return CreateWritePlan(roots, package.Media.Files);
196 }
197
198 private static AnkiPackageWritePlan CreateWritePlan(IReadOnlyList<AnkiDeck> roots, IEnumerable<AnkiMediaFile>? packageMedia)
199 {
200 var validation = AnkiValidator.Validate(roots);
201 if (!validation.IsValid)
202 {
203 throw new AnkiValidationException(validation);
204 }
205
206 var mediaByName = new Dictionary<string, AnkiMediaFile>(StringComparer.Ordinal);
207 if (packageMedia is not null)
208 {
209 foreach (var media in packageMedia)
210 {
211 AddMedia(mediaByName, media);
212 }
213 }
214
215 foreach (var media in roots.SelectMany(root => root.Traverse()).SelectMany(deck => deck.Media.Files))
216 {
217 AddMedia(mediaByName, media);
218 }
219
220 return new AnkiPackageWritePlan(roots, mediaByName.Values.OrderBy(media => media.FileName, StringComparer.Ordinal).ToArray());
221 }
222
223 private static void AddMedia(Dictionary<string, AnkiMediaFile> mediaByName, AnkiMediaFile media)
224 {
225 if (!mediaByName.TryGetValue(media.FileName, out var existing))
226 {
227 mediaByName.Add(media.FileName, media);
228 return;
229 }
230
231 if (existing.Length != media.Length || !string.Equals(existing.Sha256, media.Sha256, StringComparison.Ordinal))
232 {
233 throw new InvalidOperationException($"Media filename '{media.FileName}' is registered with conflicting content.");
234 }
235 }
236
237 private static void EnsureWritable(Stream destination)
238 {
239 if (!destination.CanWrite)
240 {
241 throw new ArgumentException("Package output must be writable.", nameof(destination));
242 }
243 }
244
245 private static async Task WritePathAsync(AnkiPackageWritePlan plan, string path, CancellationToken cancellationToken)
246 {
247 var destinationPath = Path.GetFullPath(path);
248 var destinationDirectory = Path.GetDirectoryName(destinationPath) ?? Directory.GetCurrentDirectory();
249 var temporaryPath = Path.Combine(destinationDirectory, ".AnkiIO-write-" + Guid.NewGuid().ToString("N") + ".tmp");
250 var committed = false;
251 try
252 {
253 await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
254 {
255 await WriteArchiveAsync(plan, stream, cancellationToken).ConfigureAwait(false);
256 await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
257 }
258
259 cancellationToken.ThrowIfCancellationRequested();
260 File.Move(temporaryPath, destinationPath, overwrite: true);
261 committed = true;
262 }
263 finally
264 {
265 if (!committed && File.Exists(temporaryPath))
266 {
267 File.Delete(temporaryPath);
268 }
269 }
270 }
271
272 private static async Task WriteArchiveAsync(AnkiPackageWritePlan plan, Stream destination, CancellationToken cancellationToken)
273 {
274 var tempDirectory = Path.Combine(Path.GetTempPath(), "AnkiIO-write-" + Guid.NewGuid().ToString("N"));
275 Directory.CreateDirectory(tempDirectory);
276 try
277 {
278 var databasePath = Path.Combine(tempDirectory, "collection.anki2");
279 await LegacyCollectionDatabase.WriteAsync(databasePath, plan.Roots, cancellationToken).ConfigureAwait(false);
280 using var archive = new ZipArchive(destination, ZipArchiveMode.Create, leaveOpen: true);
281 var collectionEntry = archive.CreateEntry("collection.anki2", CompressionLevel.Optimal);
282 await using (var entryStream = collectionEntry.Open())
283 await using (var database = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
284 {
285 await database.CopyToAsync(entryStream, cancellationToken).ConfigureAwait(false);
286 }
287
288 var map = new SortedDictionary<string, string>(StringComparer.Ordinal);
289 for (var index = 0; index < plan.Media.Count; index++)
290 {
291 cancellationToken.ThrowIfCancellationRequested();
292 var item = plan.Media[index];
293 var entryName = index.ToString(System.Globalization.CultureInfo.InvariantCulture);
294 map.Add(entryName, item.FileName);
295 var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
296 await using var destinationMedia = entry.Open();
297 await using var sourceMedia = await item.OpenReadAsync(cancellationToken).ConfigureAwait(false);
298 using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
299 var buffer = new byte[81920];
300 int read;
301 while ((read = await sourceMedia.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
302 {
303 hasher.AppendData(buffer, 0, read);
304 await destinationMedia.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
305 }
306
307 var actual = Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant();
308 if (!string.Equals(actual, item.Sha256, StringComparison.Ordinal))
309 {
310 throw new InvalidDataException($"Media '{item.FileName}' changed after registration; expected SHA-256 {item.Sha256}, got {actual}.");
311 }
312 }
313
314 var mediaEntry = archive.CreateEntry("media", CompressionLevel.Optimal);
315 await using var mediaStream = mediaEntry.Open();
316 await JsonSerializer.SerializeAsync(mediaStream, map, cancellationToken: cancellationToken).ConfigureAwait(false);
317 }
318 finally
319 {
320 if (Directory.Exists(tempDirectory))
321 {
322 Directory.Delete(tempDirectory, recursive: true);
323 }
324 }
325 }
326
327}
Builds one named deck hierarchy and acts as the root for validation and export.
Definition AnkiDeck.cs:31
IReadOnlyCollection< AnkiMediaFile > Files
Gets a snapshot of registered files in deterministic filename order.
Writes validated deck data as a legacy-compatible .apkg archive accepted by Anki 26....
static async Task WriteAsync(AnkiDeck deck, Stream destination, CancellationToken cancellationToken=default)
Writes one deck hierarchy to a writable caller-owned stream and leaves the stream open.
static async Task WriteAsync(AnkiDeck deck, string path, CancellationToken cancellationToken=default)
Writes one deck hierarchy to a package file without opening or modifying an Anki profile.
static async Task WriteAsync(AnkiPackage package, Stream destination, CancellationToken cancellationToken=default)
Writes every hierarchy and all retained media in a package to a caller-owned stream and leaves it ope...
static async Task WriteAsync(AnkiPackage package, string path, CancellationToken cancellationToken=default)
Writes every hierarchy and all retained media in a previously read package to a package file.
Contains the supported deck graphs, media, and diagnostics read from one Anki package.
IReadOnlyList< AnkiDeck > Decks
Gets a fixed collection of the package's top-level deck hierarchies.
AnkiMediaCollection Media
Gets the media payloads eagerly extracted from the archive.