75 public static async Task
WriteAsync(
AnkiDeck deck,
string path, CancellationToken cancellationToken =
default)
77 ArgumentNullException.ThrowIfNull(deck);
78 ArgumentException.ThrowIfNullOrWhiteSpace(path);
79 var plan = CreateWritePlan([deck], packageMedia:
null);
80 await WritePathAsync(plan, path, cancellationToken).ConfigureAwait(
false);
109 public static async Task
WriteAsync(
AnkiPackage package,
string path, CancellationToken cancellationToken =
default)
111 ArgumentNullException.ThrowIfNull(package);
112 ArgumentException.ThrowIfNullOrWhiteSpace(path);
113 var plan = CreatePackageWritePlan(package);
114 await WritePathAsync(plan, path, cancellationToken).ConfigureAwait(
false);
143 public static async Task
WriteAsync(
AnkiDeck deck, Stream destination, CancellationToken cancellationToken =
default)
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);
178 public static async Task
WriteAsync(
AnkiPackage package, Stream destination, CancellationToken cancellationToken =
default)
180 ArgumentNullException.ThrowIfNull(package);
181 ArgumentNullException.ThrowIfNull(destination);
182 EnsureWritable(destination);
183 var plan = CreatePackageWritePlan(package);
184 await WriteArchiveAsync(plan, destination, cancellationToken).ConfigureAwait(
false);
187 private static AnkiPackageWritePlan CreatePackageWritePlan(
AnkiPackage package)
189 var roots = package.
Decks.ToArray();
190 if (roots.Length == 0)
192 throw new ArgumentException(
"A package must contain at least one top-level deck.", nameof(package));
195 return CreateWritePlan(roots, package.
Media.
Files);
198 private static AnkiPackageWritePlan CreateWritePlan(IReadOnlyList<AnkiDeck> roots, IEnumerable<AnkiMediaFile>? packageMedia)
200 var validation = AnkiValidator.Validate(roots);
201 if (!validation.IsValid)
203 throw new AnkiValidationException(validation);
206 var mediaByName =
new Dictionary<string, AnkiMediaFile>(StringComparer.Ordinal);
207 if (packageMedia is not
null)
209 foreach (var media
in packageMedia)
211 AddMedia(mediaByName, media);
215 foreach (var media
in roots.SelectMany(root => root.Traverse()).SelectMany(deck => deck.Media.Files))
217 AddMedia(mediaByName, media);
220 return new AnkiPackageWritePlan(roots, mediaByName.Values.OrderBy(media => media.FileName, StringComparer.Ordinal).ToArray());
223 private static void AddMedia(Dictionary<string, AnkiMediaFile> mediaByName, AnkiMediaFile media)
225 if (!mediaByName.TryGetValue(media.FileName, out var existing))
227 mediaByName.Add(media.FileName, media);
231 if (existing.Length != media.Length || !
string.Equals(existing.Sha256, media.Sha256, StringComparison.Ordinal))
233 throw new InvalidOperationException($
"Media filename '{media.FileName}' is registered with conflicting content.");
237 private static void EnsureWritable(Stream destination)
239 if (!destination.CanWrite)
241 throw new ArgumentException(
"Package output must be writable.", nameof(destination));
245 private static async Task WritePathAsync(AnkiPackageWritePlan plan,
string path, CancellationToken cancellationToken)
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;
253 await
using (var stream =
new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
255 await WriteArchiveAsync(plan, stream, cancellationToken).ConfigureAwait(
false);
256 await stream.FlushAsync(cancellationToken).ConfigureAwait(
false);
259 cancellationToken.ThrowIfCancellationRequested();
260 File.Move(temporaryPath, destinationPath, overwrite:
true);
265 if (!committed && File.Exists(temporaryPath))
267 File.Delete(temporaryPath);
272 private static async Task WriteArchiveAsync(AnkiPackageWritePlan plan, Stream destination, CancellationToken cancellationToken)
274 var tempDirectory = Path.Combine(Path.GetTempPath(),
"AnkiIO-write-" + Guid.NewGuid().ToString(
"N"));
275 Directory.CreateDirectory(tempDirectory);
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))
285 await database.CopyToAsync(entryStream, cancellationToken).ConfigureAwait(
false);
288 var map =
new SortedDictionary<string, string>(StringComparer.Ordinal);
289 for (var index = 0; index < plan.Media.Count; index++)
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];
301 while ((read = await sourceMedia.ReadAsync(buffer, cancellationToken).ConfigureAwait(
false)) > 0)
303 hasher.AppendData(buffer, 0, read);
304 await destinationMedia.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(
false);
307 var actual = Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant();
308 if (!
string.Equals(actual, item.Sha256, StringComparison.Ordinal))
310 throw new InvalidDataException($
"Media '{item.FileName}' changed after registration; expected SHA-256 {item.Sha256}, got {actual}.");
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);
320 if (Directory.Exists(tempDirectory))
322 Directory.Delete(tempDirectory, recursive:
true);