Persist file categories on the index and classify archives and unknown types during idle maintenance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 11:37:29 +02:00
parent b33a78dbbe
commit 222b5d9969
42 changed files with 1719 additions and 80 deletions

View File

@@ -0,0 +1,160 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Storage.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
namespace Explorer.Application.Tests;
public class EntryClassificationServiceTests
{
[Fact]
public async Task Archive_children_override_zip_as_photos()
{
await using var store = await OpenStore();
var source = await AddSource(store, @"C:\media");
var zip = await Upsert(store, new IndexEntry
{
SourceId = source.Id,
Name = "photos.zip",
NameNorm = "photos.zip",
Extension = "zip",
PathRel = "photos.zip",
LastSeenUtc = DateTimeOffset.UtcNow
});
await Upsert(store, Child(source.Id, zip.Id, "a.jpg", "jpg"));
await Upsert(store, Child(source.Id, zip.Id, "b.jpg", "jpg"));
await Upsert(store, Child(source.Id, zip.Id, "c.jpg", "jpg"));
var service = Create(store, new MemoryEnumerator());
service.Resume();
Assert.True(await service.ProcessPendingAsync());
var loaded = await store.Entries.GetAsync(zip.Id);
Assert.Equal(FileCategory.Photos, loaded!.Category);
Assert.Equal(CategorySources.ArchiveContents, loaded.CategorySource);
Assert.False(await service.ProcessPendingAsync());
}
[Fact]
public async Task Signature_classifies_jpeg_without_extension()
{
var root = Path.Combine(Path.GetTempPath(), "ew-classify", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var file = Path.Combine(root, "pic.bin");
File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01]);
await using var store = await OpenStore();
var source = await AddSource(store, root);
var entry = await Upsert(store, new IndexEntry
{
SourceId = source.Id,
Name = "pic.bin",
NameNorm = "pic.bin",
Extension = "bin",
PathRel = "pic.bin",
LastSeenUtc = DateTimeOffset.UtcNow
});
Assert.Equal(FileCategory.Unknown, entry.Category);
var enumerator = new MemoryEnumerator();
enumerator.Add(new FileSystemItem { FullPath = file, Name = "pic.bin" });
var service = Create(store, enumerator);
service.Resume();
Assert.True(await service.ProcessPendingAsync());
var loaded = await store.Entries.GetAsync(entry.Id);
Assert.Equal(FileCategory.Photos, loaded!.Category);
Assert.Equal(CategorySources.Mime, loaded.CategorySource);
}
[Fact]
public async Task Pause_skips_work()
{
await using var store = await OpenStore();
var source = await AddSource(store, @"C:\media");
var zip = await Upsert(store, new IndexEntry
{
SourceId = source.Id,
Name = "photos.zip",
NameNorm = "photos.zip",
Extension = "zip",
PathRel = "photos.zip",
LastSeenUtc = DateTimeOffset.UtcNow
});
await Upsert(store, Child(source.Id, zip.Id, "a.jpg", "jpg"));
var service = Create(store, new MemoryEnumerator());
Assert.True(service.IsPaused);
Assert.False(await service.ProcessPendingAsync());
var loaded = await store.Entries.GetAsync(zip.Id);
Assert.Equal(FileCategory.Archive, loaded!.Category);
}
private static EntryClassificationService Create(IIndexStore store, IFileSystemEnumerator enumerator)
=> new(
store,
enumerator,
new NeverHydrate(),
NullLogger<EntryClassificationService>.Instance);
private static async Task<SqliteIndexStore> OpenStore()
{
var db = Path.Combine(Path.GetTempPath(), "ew-classify", Guid.NewGuid().ToString("N"), "index.db");
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
await store.OpenAsync();
return store;
}
private static async Task<Source> AddSource(IIndexStore store, string root)
{
var source = new Source
{
StableKey = Guid.NewGuid().ToString("N"),
Kind = SourceKind.NtfsLocal,
DisplayName = "Test",
LastRootPath = root,
Status = SourceStatus.Online
};
source.Id = await store.Sources.UpsertAsync(source);
return source;
}
private static async Task<IndexEntry> Upsert(IIndexStore store, IndexEntry entry)
{
entry.Id = await store.Entries.UpsertAsync(entry);
return (await store.Entries.GetAsync(entry.Id))!;
}
private static IndexEntry Child(long sourceId, long parentId, string name, string ext)
=> new()
{
SourceId = sourceId,
ParentId = parentId,
Name = name,
NameNorm = name,
Extension = ext,
PathRel = name,
LastSeenUtc = DateTimeOffset.UtcNow
};
private sealed class NeverHydrate : IHydrationGuard
{
public bool WouldHydrateOnRead(FileSystemItem item) => false;
public bool WouldHydrateOnRead(int attributes, CloudAvailability? availability) => false;
public Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class MemoryEnumerator : IFileSystemEnumerator
{
private readonly Dictionary<string, FileSystemItem> _items = new(StringComparer.OrdinalIgnoreCase);
public void Add(FileSystemItem item) => _items[item.FullPath] = item;
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath) => [];
public FileSystemItem? GetItem(string path)
=> _items.TryGetValue(path, out var item) ? item : null;
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
{
error = null;
return [];
}
}
}

View File

@@ -132,6 +132,40 @@ public class ReorganizePlannerTests
Assert.Null(fs.GetItem(@"C:\Pictures\photo.jpg"));
}
[Fact]
public void Prefers_indexed_archive_contents_over_extension()
{
var fs = Tree()
.Dir(@"C:\Downloads")
.File(@"C:\Downloads\photos.zip", 4, T0)
.Dir(@"C:\Pictures")
.Dir(@"C:\Archive");
var indexed = new Dictionary<string, IndexEntry>(StringComparer.OrdinalIgnoreCase)
{
["photos.zip"] = new IndexEntry
{
Name = "photos.zip",
NameNorm = "photos.zip",
PathRel = "photos.zip",
Category = FileCategory.Photos,
CategorySource = CategorySources.ArchiveContents,
CategoryReason = "Archive contents are mostly photos."
}
};
var plan = new ReorganizePlanner().Build(
@"C:\Downloads",
Map(),
fs,
_ => true,
_ => false,
now: T0,
pathExists: _ => false,
indexedByName: indexed);
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("photos.zip") && o.DestinationPath == @"C:\Pictures\photos.zip");
Assert.DoesNotContain(plan.Operations, o => o.DestinationPath == @"C:\Archive\photos.zip");
}
private static OperationPlan Build(
IFileSystemEnumerator fs,
Action<OrganizeDestinations>? configure = null,