32 private readonly Dictionary<string, AnkiMediaFile> files =
new(StringComparer.Ordinal);
45 public IReadOnlyCollection<AnkiMediaFile>
Files => Array.AsReadOnly(files.Values.OrderBy(file => file.FileName, StringComparer.Ordinal).ToArray());
72 public async Task<AnkiMediaFile>
AddFileAsync(
string path, CancellationToken cancellationToken =
default)
74 ArgumentException.ThrowIfNullOrWhiteSpace(path);
75 var fullPath = Path.GetFullPath(path);
76 var name = Path.GetFileName(fullPath);
77 ValidateFileName(name);
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 =>
83 token.ThrowIfCancellationRequested();
84 return ValueTask.FromResult<Stream>(new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan));
86 return AddChecked(media);
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 =>
113 token.ThrowIfCancellationRequested();
114 return ValueTask.FromResult<Stream>(new MemoryStream(bytes, writable: false));
116 return AddChecked(media);
127 public bool Remove(
string fileName) => files.Remove(fileName);
129 internal static void ValidateFileName(
string fileName)
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))
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));
144 private static bool IsWindowsReservedName(
string fileName)
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))
156 return stem.Length == 4
157 && (stem.StartsWith(
"COM", StringComparison.OrdinalIgnoreCase) || stem.StartsWith(
"LPT", StringComparison.OrdinalIgnoreCase))
158 && stem[3] is >=
'1' and <=
'9';
161 private AnkiMediaFile AddChecked(AnkiMediaFile media)
163 if (files.TryGetValue(media.FileName, out var existing))
165 if (!
string.Equals(existing.Sha256, media.Sha256, StringComparison.Ordinal))
167 throw new InvalidOperationException($
"Media filename '{media.FileName}' has colliding content.");
173 files.Add(media.FileName, media);