using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Ionic.Zip;
using Microsoft.EntityFrameworkCore;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.IPC;
using osu.Game.Overlays.Notifications;
using FileInfo = osu.Game.IO.FileInfo;
namespace osu.Game.Database
{
///
/// Encapsulates a model store class to give it import functionality.
/// Adds cross-functionality with to give access to the central file store for the provided model.
///
/// The model type.
/// The associated file join type.
public abstract class ArchiveModelManager : ICanImportArchives
where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete
where TFileModel : INamedFileInfo, new()
{
///
/// Set an endpoint for notifications to be posted to.
///
public Action PostNotification { protected get; set; }
///
/// Fired when a new becomes available in the database.
///
public event Action ItemAdded;
///
/// Fired when a is removed from the database.
///
public event Action ItemRemoved;
public virtual string[] HandledExtensions => new[] { ".zip" };
protected readonly FileStore Files;
protected readonly IDatabaseContextFactory ContextFactory;
protected readonly MutableDatabaseBackedStore ModelStore;
// ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised)
private ArchiveImportIPCChannel ipc;
protected ArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, MutableDatabaseBackedStore modelStore, IIpcHost importHost = null)
{
ContextFactory = contextFactory;
ModelStore = modelStore;
ModelStore.ItemAdded += s => ItemAdded?.Invoke(s);
ModelStore.ItemRemoved += s => ItemRemoved?.Invoke(s);
Files = new FileStore(contextFactory, storage);
if (importHost != null)
ipc = new ArchiveImportIPCChannel(importHost, this);
}
///
/// Import one or more items from filesystem .
/// This will post notifications tracking progress.
///
/// One or more archive locations on disk.
public void Import(params string[] paths)
{
var notification = new ProgressNotification
{
Text = "Import is initialising...",
CompletionText = "Import successful!",
Progress = 0,
State = ProgressNotificationState.Active,
};
PostNotification?.Invoke(notification);
List imported = new List();
int i = 0;
foreach (string path in paths)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
try
{
notification.Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}";
using (ArchiveReader reader = getReaderFrom(path))
imported.Add(Import(reader));
notification.Progress = (float)++i / paths.Length;
// We may or may not want to delete the file depending on where it is stored.
// e.g. reconstructing/repairing database with items from default storage.
// Also, not always a single file, i.e. for LegacyFilesystemReader
// TODO: Add a check to prevent files from storage to be deleted.
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (Exception e)
{
Logger.Error(e, $@"Could not delete original file after import ({Path.GetFileName(path)})");
}
}
catch (Exception e)
{
e = e.InnerException ?? e;
Logger.Error(e, $@"Could not import ({Path.GetFileName(path)})");
}
}
notification.State = ProgressNotificationState.Completed;
}
///
/// Import an item from an .
///
/// The archive to be imported.
public TModel Import(ArchiveReader archive)
{
using (ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes.
{
// create a new model (don't yet add to database)
var item = CreateModel(archive);
var existing = CheckForExisting(item);
if (existing != null) return existing;
item.Files = createFileInfos(archive, Files);
Populate(item, archive);
// import to store
ModelStore.Add(item);
return item;
}
}
///
/// Import an item from a .
///
/// The model to be imported.
public void Import(TModel item) => ModelStore.Add(item);
///
/// Delete an item from the manager.
/// Is a no-op for already deleted items.
///
/// The item to delete.
public void Delete(TModel item)
{
using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
context.ChangeTracker.AutoDetectChangesEnabled = false;
// re-fetch the model on the import context.
var foundModel = queryModel().Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == item.ID);
if (foundModel.DeletePending) return;
if (ModelStore.Delete(foundModel))
Files.Dereference(foundModel.Files.Select(f => f.FileInfo).ToArray());
context.ChangeTracker.AutoDetectChangesEnabled = true;
}
}
///
/// Restore all items that were previously deleted.
/// This will post notifications tracking progress.
///
public void UndeleteAll()
{
var deletedItems = queryModel().Where(m => m.DeletePending).ToList();
if (!deletedItems.Any()) return;
var notification = new ProgressNotification
{
CompletionText = "Restored all deleted items!",
Progress = 0,
State = ProgressNotificationState.Active,
};
PostNotification?.Invoke(notification);
int i = 0;
foreach (var item in deletedItems)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
notification.Text = $"Restoring ({i} of {deletedItems.Count})";
notification.Progress = (float)++i / deletedItems.Count;
Undelete(item);
}
notification.State = ProgressNotificationState.Completed;
}
///
/// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
///
/// The item to restore
public void Undelete(TModel item)
{
using (var usage = ContextFactory.GetForWrite())
{
usage.Context.ChangeTracker.AutoDetectChangesEnabled = false;
if (!ModelStore.Undelete(item)) return;
Files.Reference(item.Files.Select(f => f.FileInfo).ToArray());
usage.Context.ChangeTracker.AutoDetectChangesEnabled = true;
}
}
///
/// Create all required s for the provided archive, adding them to the global file store.
///
private List createFileInfos(ArchiveReader reader, FileStore files)
{
var fileInfos = new List();
// import files to manager
foreach (string file in reader.Filenames)
using (Stream s = reader.GetStream(file))
fileInfos.Add(new TFileModel
{
Filename = file,
FileInfo = files.Add(s)
});
return fileInfos;
}
///
/// Create a barebones model from the provided archive.
/// Actual expensive population should be done in ; this should just prepare for duplicate checking.
///
///
///
protected abstract TModel CreateModel(ArchiveReader archive);
///
/// Populate the provided model completely from the given archive.
/// After this method, the model should be in a state ready to commit to a store.
///
/// The model to populate.
/// The archive to use as a reference for population.
protected virtual void Populate(TModel model, ArchiveReader archive)
{
}
protected virtual TModel CheckForExisting(TModel model) => null;
private DbSet queryModel() => ContextFactory.Get().Set();
///
/// Creates an from a valid storage path.
///
/// A file or folder path resolving the archive content.
/// A reader giving access to the archive's content.
private ArchiveReader getReaderFrom(string path)
{
if (ZipFile.IsZipFile(path))
return new ZipArchiveReader(Files.Storage.GetStream(path), Path.GetFileName(path));
return new LegacyFilesystemReader(path);
}
}
}