2021-11-25 14:14:43 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Text ;
using System.Threading ;
2021-11-29 17:07:32 +08:00
using Newtonsoft.Json ;
2021-11-25 14:14:43 +08:00
using osu.Framework.Platform ;
2022-08-10 14:48:38 +08:00
using osu.Game.Beatmaps ;
2021-11-25 14:14:43 +08:00
using osu.Game.Database ;
using osu.Game.Extensions ;
using osu.Game.IO ;
using osu.Game.IO.Archives ;
2021-11-29 17:07:32 +08:00
using Realms ;
2021-11-25 14:14:43 +08:00
namespace osu.Game.Skinning
{
2022-06-16 17:53:13 +08:00
public class SkinImporter : RealmArchiveModelImporter < SkinInfo >
2021-11-25 14:14:43 +08:00
{
2021-12-02 16:43:54 +08:00
private const string skin_info_file = "skininfo.json" ;
2021-11-25 14:14:43 +08:00
private readonly IStorageResourceProvider skinResources ;
2022-06-16 17:53:13 +08:00
private readonly ModelManager < SkinInfo > modelManager ;
2022-06-16 17:11:50 +08:00
public SkinImporter ( Storage storage , RealmAccess realm , IStorageResourceProvider skinResources )
2022-01-24 18:59:58 +08:00
: base ( storage , realm )
2021-11-25 14:14:43 +08:00
{
this . skinResources = skinResources ;
2022-06-16 17:53:13 +08:00
modelManager = new ModelManager < SkinInfo > ( storage , realm ) ;
2021-11-25 14:14:43 +08:00
}
public override IEnumerable < string > HandledExtensions = > new [ ] { ".osk" } ;
protected override string [ ] HashableFileTypes = > new [ ] { ".ini" , ".json" } ;
2022-12-16 19:18:02 +08:00
protected override bool ShouldDeleteArchive ( string path ) = > Path . GetExtension ( path ) . ToLowerInvariant ( ) = = @".osk" ;
2021-11-25 14:14:43 +08:00
protected override SkinInfo CreateModel ( ArchiveReader archive ) = > new SkinInfo { Name = archive . Name ? ? @"No name" } ;
private const string unknown_creator_string = @"Unknown" ;
2022-01-13 15:27:07 +08:00
protected override void Populate ( SkinInfo model , ArchiveReader ? archive , Realm realm , CancellationToken cancellationToken = default )
2021-11-29 17:07:32 +08:00
{
2022-08-10 14:48:38 +08:00
var skinInfoFile = model . GetFile ( skin_info_file ) ;
2021-12-02 16:43:54 +08:00
if ( skinInfoFile ! = null )
{
try
{
using ( var existingStream = Files . Storage . GetStream ( skinInfoFile . File . GetStoragePath ( ) ) )
using ( var reader = new StreamReader ( existingStream ) )
{
var deserialisedSkinInfo = JsonConvert . DeserializeObject < SkinInfo > ( reader . ReadToEnd ( ) ) ;
if ( deserialisedSkinInfo ! = null )
{
// for now we only care about the instantiation info.
// eventually we probably want to transfer everything across.
model . InstantiationInfo = deserialisedSkinInfo . InstantiationInfo ;
}
}
}
catch ( Exception e )
{
LogForModel ( model , $"Error during {skin_info_file} parsing, falling back to default" , e ) ;
// Not sure if we should still run the import in the case of failure here, but let's do so for now.
model . InstantiationInfo = string . Empty ;
}
}
// Always rewrite instantiation info (even after parsing in from the skin json) for sanity.
model . InstantiationInfo = createInstance ( model ) . GetType ( ) . GetInvariantInstantiationInfo ( ) ;
2021-11-29 17:07:32 +08:00
checkSkinIniMetadata ( model , realm ) ;
}
private void checkSkinIniMetadata ( SkinInfo item , Realm realm )
2021-11-25 14:14:43 +08:00
{
var instance = createInstance ( item ) ;
// This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations.
// `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above.
string skinIniSourcedName = instance . Configuration . SkinInfo . Name ;
string skinIniSourcedCreator = instance . Configuration . SkinInfo . Creator ;
string archiveName = item . Name . Replace ( @".osk" , string . Empty , StringComparison . OrdinalIgnoreCase ) ;
2021-11-29 17:07:32 +08:00
bool isImport = ! item . IsManaged ;
2021-11-25 14:14:43 +08:00
if ( isImport )
{
item . Name = ! string . IsNullOrEmpty ( skinIniSourcedName ) ? skinIniSourcedName : archiveName ;
item . Creator = ! string . IsNullOrEmpty ( skinIniSourcedCreator ) ? skinIniSourcedCreator : unknown_creator_string ;
// For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata.
// In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications.
// In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin.
2022-04-11 22:57:27 +08:00
if ( archiveName ! = item . Name
// lazer exports use this format
2023-03-13 19:01:26 +08:00
// GetValidFilename accounts for skins with non-ASCII characters in the name that have been exported by lazer.
& & archiveName ! = item . GetDisplayString ( ) . GetValidFilename ( ) )
2021-11-25 14:14:43 +08:00
item . Name = @ $"{item.Name} [{archiveName}]" ;
}
// By this point, the metadata in SkinInfo will be correct.
// Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching.
// This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place.
if ( skinIniSourcedName ! = item . Name )
2021-11-29 17:07:32 +08:00
updateSkinIniMetadata ( item , realm ) ;
2021-11-25 14:14:43 +08:00
}
2021-11-29 17:07:32 +08:00
private void updateSkinIniMetadata ( SkinInfo item , Realm realm )
2021-11-25 14:14:43 +08:00
{
string nameLine = @ $"Name: {item.Name}" ;
string authorLine = @ $"Author: {item.Creator}" ;
string [ ] newLines =
{
@"// The following content was automatically added by osu! during import, based on filename / folder metadata." ,
@"[General]" ,
nameLine ,
authorLine ,
} ;
2022-08-10 14:48:38 +08:00
var existingFile = item . GetFile ( @"skin.ini" ) ;
2021-11-25 14:14:43 +08:00
if ( existingFile = = null )
{
// In the case a skin doesn't have a skin.ini yet, let's create one.
writeNewSkinIni ( ) ;
}
2021-11-29 17:07:32 +08:00
else
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
using ( Stream stream = new MemoryStream ( ) )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
using ( var sw = new StreamWriter ( stream , Encoding . UTF8 , 1024 , true ) )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
using ( var existingStream = Files . Storage . GetStream ( existingFile . File . GetStoragePath ( ) ) )
using ( var sr = new StreamReader ( existingStream ) )
{
string? line ;
while ( ( line = sr . ReadLine ( ) ) ! = null )
sw . WriteLine ( line ) ;
}
sw . WriteLine ( ) ;
foreach ( string line in newLines )
2021-11-25 14:14:43 +08:00
sw . WriteLine ( line ) ;
}
2022-06-16 17:53:13 +08:00
modelManager . ReplaceFile ( existingFile , stream , realm ) ;
2021-11-25 14:14:43 +08:00
}
}
2021-11-29 17:07:32 +08:00
// The hash is already populated at this point in import.
// As we have changed files, it needs to be recomputed.
item . Hash = ComputeHash ( item ) ;
2021-11-25 14:14:43 +08:00
void writeNewSkinIni ( )
{
using ( Stream stream = new MemoryStream ( ) )
{
using ( var sw = new StreamWriter ( stream , Encoding . UTF8 , 1024 , true ) )
{
foreach ( string line in newLines )
sw . WriteLine ( line ) ;
}
2022-06-16 17:53:13 +08:00
modelManager . AddFile ( item , stream , @"skin.ini" , realm ) ;
2021-11-25 14:14:43 +08:00
}
2021-11-29 17:07:32 +08:00
item . Hash = ComputeHash ( item ) ;
2021-11-25 14:14:43 +08:00
}
}
private Skin createInstance ( SkinInfo item ) = > item . CreateInstance ( skinResources ) ;
2021-11-29 17:07:32 +08:00
2023-02-02 17:42:33 +08:00
/// <summary>
2023-02-03 14:18:01 +08:00
/// Save a skin, serialising any changes to skin layouts to relevant JSON structures.
2023-02-02 17:42:33 +08:00
/// </summary>
/// <returns>Whether any change actually occurred.</returns>
public bool Save ( Skin skin )
2021-11-29 17:07:32 +08:00
{
2023-02-02 17:42:33 +08:00
bool hadChanges = false ;
2021-11-29 17:07:32 +08:00
skin . SkinInfo . PerformWrite ( s = >
{
2022-09-15 15:21:39 +08:00
// Update for safety
s . InstantiationInfo = skin . GetType ( ) . GetInvariantInstantiationInfo ( ) ;
2021-12-02 16:43:54 +08:00
// Serialise out the SkinInfo itself.
string skinInfoJson = JsonConvert . SerializeObject ( s , new JsonSerializerSettings { Formatting = Formatting . Indented } ) ;
using ( var streamContent = new MemoryStream ( Encoding . UTF8 . GetBytes ( skinInfoJson ) ) )
{
2023-07-06 12:37:42 +08:00
modelManager . AddFile ( s , streamContent , skin_info_file , s . Realm ! ) ;
2021-12-02 16:43:54 +08:00
}
// Then serialise each of the drawable component groups into respective files.
2023-02-16 18:58:04 +08:00
foreach ( var drawableInfo in skin . LayoutInfos )
2021-11-29 17:07:32 +08:00
{
string json = JsonConvert . SerializeObject ( drawableInfo . Value , new JsonSerializerSettings { Formatting = Formatting . Indented } ) ;
using ( var streamContent = new MemoryStream ( Encoding . UTF8 . GetBytes ( json ) ) )
{
string filename = @ $"{drawableInfo.Key}.json" ;
2022-08-10 14:48:38 +08:00
var oldFile = s . GetFile ( filename ) ;
2021-11-29 17:07:32 +08:00
if ( oldFile ! = null )
2023-07-06 12:37:42 +08:00
modelManager . ReplaceFile ( oldFile , streamContent , s . Realm ! ) ;
2021-11-29 17:07:32 +08:00
else
2023-07-06 12:37:42 +08:00
modelManager . AddFile ( s , streamContent , filename , s . Realm ! ) ;
2021-11-29 17:07:32 +08:00
}
}
2023-02-02 17:42:33 +08:00
string newHash = ComputeHash ( s ) ;
hadChanges = newHash ! = s . Hash ;
s . Hash = newHash ;
2021-11-29 17:07:32 +08:00
} ) ;
2023-02-02 17:42:33 +08:00
return hadChanges ;
2021-11-29 17:07:32 +08:00
}
2021-11-25 14:14:43 +08:00
}
}