AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiMediaCollection.cs
1using System.Security.Cryptography;
2
3namespace AnkiIO;
4
30public sealed class AnkiMediaCollection
31{
32 private readonly Dictionary<string, AnkiMediaFile> files = new(StringComparer.Ordinal);
33
37 {
38 }
39
45 public IReadOnlyCollection<AnkiMediaFile> Files => Array.AsReadOnly(files.Values.OrderBy(file => file.FileName, StringComparer.Ordinal).ToArray());
46
72 public async Task<AnkiMediaFile> AddFileAsync(string path, CancellationToken cancellationToken = default)
73 {
74 ArgumentException.ThrowIfNullOrWhiteSpace(path);
75 var fullPath = Path.GetFullPath(path);
76 var name = Path.GetFileName(fullPath);
77 ValidateFileName(name);
78
79 await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
80 var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)).ToLowerInvariant();
81 var media = new AnkiMediaFile(name, stream.Length, hash, token =>
82 {
83 token.ThrowIfCancellationRequested();
84 return ValueTask.FromResult<Stream>(new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan));
85 });
86 return AddChecked(media);
87 }
88
106 public AnkiMediaFile AddBytes(string fileName, ReadOnlySpan<byte> content)
107 {
108 ValidateFileName(fileName);
109 var bytes = content.ToArray();
110 var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
111 var media = new AnkiMediaFile(fileName, bytes.LongLength, hash, token =>
112 {
113 token.ThrowIfCancellationRequested();
114 return ValueTask.FromResult<Stream>(new MemoryStream(bytes, writable: false));
115 });
116 return AddChecked(media);
117 }
118
127 public bool Remove(string fileName) => files.Remove(fileName);
128
129 internal static void ValidateFileName(string fileName)
130 {
131 ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
132 if (Path.IsPathRooted(fileName)
133 || fileName is "." or ".."
134 || fileName.EndsWith(' ')
135 || fileName.EndsWith('.')
136 || fileName.Any(character => char.IsControl(character) || "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal))
137 || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
138 || IsWindowsReservedName(fileName))
139 {
140 throw new ArgumentException("Media names must be portable simple filenames without reserved names, path separators, control characters, or trailing spaces and periods.", nameof(fileName));
141 }
142 }
143
144 private static bool IsWindowsReservedName(string fileName)
145 {
146 var period = fileName.IndexOf('.');
147 var stem = period < 0 ? fileName : fileName[..period];
148 if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
149 || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
150 || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
151 || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase))
152 {
153 return true;
154 }
155
156 return stem.Length == 4
157 && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
158 && stem[3] is >= '1' and <= '9';
159 }
160
161 private AnkiMediaFile AddChecked(AnkiMediaFile media)
162 {
163 if (files.TryGetValue(media.FileName, out var existing))
164 {
165 if (!string.Equals(existing.Sha256, media.Sha256, StringComparison.Ordinal))
166 {
167 throw new InvalidOperationException($"Media filename '{media.FileName}' has colliding content.");
168 }
169
170 return existing;
171 }
172
173 files.Add(media.FileName, media);
174 return media;
175 }
176}
async Task< AnkiMediaFile > AddFileAsync(string path, CancellationToken cancellationToken=default)
Hashes and registers a local file without loading it into memory.
bool Remove(string fileName)
Removes a media registration without deleting or invalidating its source content.
AnkiMediaFile AddBytes(string fileName, ReadOnlySpan< byte > content)
Copies and registers reusable in-memory content.
IReadOnlyCollection< AnkiMediaFile > Files
Gets a snapshot of registered files in deterministic filename order.
AnkiMediaCollection()
Initializes an empty media collection.
Describes one registered Anki media payload and provides repeatable streaming access to it.