76 public static async Task<AnkiPackage>
ReadAsync(
string path,
AnkiPackageLimits? limits =
null, CancellationToken cancellationToken =
default)
78 ArgumentException.ThrowIfNullOrWhiteSpace(path);
81 await
using var stream =
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
82 return await
ReadAsync(stream, limits, cancellationToken).ConfigureAwait(
false);
115 public static async Task<AnkiPackage>
ReadAsync(Stream source,
AnkiPackageLimits? limits =
null, CancellationToken cancellationToken =
default)
117 ArgumentNullException.ThrowIfNull(source);
118 if (!source.CanRead || !source.CanSeek)
120 throw new ArgumentException(
"Package input must be readable and seekable so archive limits can be checked before extraction.", nameof(source));
125 var tempDirectory = Path.Combine(Path.GetTempPath(),
"AnkiIO-read-" + Guid.NewGuid().ToString(
"N"));
126 Directory.CreateDirectory(tempDirectory);
129 using var archive =
new ZipArchive(source, ZipArchiveMode.Read, leaveOpen:
true);
130 ValidateArchive(archive, limits);
131 var collectionEntry = archive.GetEntry(
"collection.anki2") ??
throw new NotSupportedException(
"This package does not contain legacy collection.anki2. Modern collection.anki21b packages require a future adapter.");
132 if (collectionEntry.Length > limits.MaximumCollectionBytes)
137 var databasePath = Path.Combine(tempDirectory,
"collection.anki2");
138 await
using (var input = collectionEntry.Open())
139 await
using (var output =
new FileStream(databasePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
141 await input.CopyToAsync(output, cancellationToken).ConfigureAwait(
false);
144 var decks = await LegacyCollectionDatabase.ReadAsync(databasePath, cancellationToken).ConfigureAwait(
false);
146 var diagnostics =
new List<AnkiDiagnostic>
148 new(
AnkiDiagnosticSeverity.Information,
"PKG001",
"Read legacy collection.anki2 package representation. Unknown SQLite columns and schema-18 protobuf metadata are not handled by this adapter."),
150 var mediaMapEntry = archive.GetEntry(
"media");
151 if (mediaMapEntry is not
null)
153 await
using var mapStream = mediaMapEntry.Open();
154 var map = await ReadMediaMapAsync(mapStream, cancellationToken).ConfigureAwait(
false);
155 foreach (var pair
in map.OrderBy(pair => pair.Key, StringComparer.Ordinal))
161 catch (ArgumentException)
166 if (pair.Key.Length == 0 || !pair.Key.All(
char.IsAsciiDigit))
171 var entry = archive.GetEntry(pair.Key) ??
throw new InvalidDataException($
"Media map references missing entry '{pair.Key}'.");
172 if (entry.Length >
int.MaxValue)
177 await
using var content = entry.Open();
178 using var memory =
new MemoryStream((
int)entry.Length);
179 await content.CopyToAsync(memory, cancellationToken).ConfigureAwait(
false);
180 media.AddBytes(pair.Value, memory.ToArray());
188 if (Directory.Exists(tempDirectory))
190 Directory.Delete(tempDirectory, recursive:
true);
195 private static async Task<IReadOnlyDictionary<string, string>> ReadMediaMapAsync(Stream stream, CancellationToken cancellationToken)
236 if (entry.FullName.Length == 0 || Path.IsPathRooted(entry.FullName) || entry.FullName.Contains(
'/') || entry.FullName.Contains(
'\\') || entry.FullName is
"." or
"..")
263 if (entry.CompressedLength > 0 && entry.Length / (
double)entry.CompressedLength > limits.MaximumCompressionRatio)