445 lines
18 KiB
C++
445 lines
18 KiB
C++
|
|
// Copyright Epic Games, Inc. All Rights Reserved.
|
||
|
|
|
||
|
|
#include "Commandlets/ImportAssetsCommandlet.h"
|
||
|
|
#include "AutomatedAssetImportData.h"
|
||
|
|
#include "Modules/ModuleManager.h"
|
||
|
|
#include "Factories/Factory.h"
|
||
|
|
#include "IAssetTools.h"
|
||
|
|
#include "AssetToolsModule.h"
|
||
|
|
#include "Serialization/JsonReader.h"
|
||
|
|
#include "Serialization/JsonSerializer.h"
|
||
|
|
#include "JsonObjectConverter.h"
|
||
|
|
#include "HAL/FileManager.h"
|
||
|
|
#include "Misc/FileHelper.h"
|
||
|
|
#include "Factories/ImportSettings.h"
|
||
|
|
#include "ISourceControlModule.h"
|
||
|
|
#include "SourceControlHelpers.h"
|
||
|
|
#include "Editor.h"
|
||
|
|
#include "FileHelpers.h"
|
||
|
|
#include "Misc/FeedbackContext.h"
|
||
|
|
#include "HAL/PlatformFilemanager.h"
|
||
|
|
#include "GameFramework/WorldSettings.h"
|
||
|
|
|
||
|
|
void UImportAssetsCommandlet::PrintUsage()
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("LogAutomatedImport Usage: LogAutomatedImport {arglist}"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("Arglist:"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-help or -?"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tDisplays this help"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-source=\"path\""));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tThe source file to import. This must be specified when importing a single asset\n[IGNORED when using -importparams]"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-dest=\"path\""));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tThe destination path in the project's content directory to import to.\nThis must be specified when importing a single asset\n[IGNORED when using -importparams]"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-factory={factory class name}"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tForces the asset to be opened with a specific UFactory class type. If not specified import type will be auto detected.\n[IGNORED when using -importparams]"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-importsettings=\"path to import settings json file\""));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tPath to a json file that has asset import parameters when importing multiple files. If this argument is used all other import arguments are ignored as they are specified in the json file"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-replaceexisting"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tWhether or not to replace existing assets when importing"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-nosourcecontrol"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tDisables source control. Prevents checking out, adding files, and submitting files"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-submitdesc"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tSubmit description/comment to use checking in to source control. If this is empty no files will be submitted"));
|
||
|
|
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("-skipreadonly"));
|
||
|
|
UE_LOG(LogAutomatedImport, Display, TEXT("\tIf an asset cannot be saved because it is read only, the commandlet will not clear the read only flag and will not save the file"));
|
||
|
|
}
|
||
|
|
|
||
|
|
UImportAssetsCommandlet::UImportAssetsCommandlet(const FObjectInitializer& ObjectInitializer)
|
||
|
|
: Super(ObjectInitializer)
|
||
|
|
{
|
||
|
|
}
|
||
|
|
|
||
|
|
bool UImportAssetsCommandlet::ParseParams(const FString& InParams)
|
||
|
|
{
|
||
|
|
TArray<FString> Tokens;
|
||
|
|
TArray<FString> Params;
|
||
|
|
TMap<FString, FString> ParamVals;
|
||
|
|
|
||
|
|
ParseCommandLine(*InParams, Tokens, Params, ParamVals);
|
||
|
|
|
||
|
|
if (Params.Contains(TEXT("?")) || Params.Contains(TEXT("help")))
|
||
|
|
{
|
||
|
|
bShowHelp = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bAllowSourceControl = !Params.Contains(TEXT("nosourcecontrol"));
|
||
|
|
|
||
|
|
GlobalImportData = NewObject<UAutomatedAssetImportData>(this);
|
||
|
|
|
||
|
|
GlobalImportData->bSkipReadOnly = Params.Contains(TEXT("skipreadonly"));
|
||
|
|
|
||
|
|
FString SourcePathParam = ParamVals.FindRef(TEXT("source"));
|
||
|
|
if (!SourcePathParam.IsEmpty())
|
||
|
|
{
|
||
|
|
GlobalImportData->Filenames.Add(SourcePathParam);
|
||
|
|
}
|
||
|
|
|
||
|
|
GlobalImportData->DestinationPath = ParamVals.FindRef(TEXT("dest"));
|
||
|
|
|
||
|
|
GlobalImportData->FactoryName = ParamVals.FindRef(TEXT("factoryname"));
|
||
|
|
|
||
|
|
GlobalImportData->bReplaceExisting = Params.Contains(TEXT("replaceexisting"));
|
||
|
|
|
||
|
|
GlobalImportData->LevelToLoad = ParamVals.FindRef(TEXT("level"));
|
||
|
|
|
||
|
|
if (!GlobalImportData->LevelToLoad.IsEmpty())
|
||
|
|
{
|
||
|
|
FText FailReason;
|
||
|
|
if (!FPackageName::IsValidLongPackageName(GlobalImportData->LevelToLoad, false, &FailReason))
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Invalid level specified: %s"), *FailReason.ToString());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ImportSettingsPath = ParamVals.FindRef(TEXT("importsettings"));
|
||
|
|
|
||
|
|
GlobalImportData->Initialize(nullptr);
|
||
|
|
|
||
|
|
if (ImportSettingsPath.IsEmpty() && (GlobalImportData->Filenames.Num() == 0 || GlobalImportData->DestinationPath.IsEmpty()))
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Invalid Arguments. Missing, Source (-source), Destination (-dest), or Import settings file (-importsettings)"));
|
||
|
|
}
|
||
|
|
const bool bEnoughParams = (ParamVals.Num() > 1) || !ImportSettingsPath.IsEmpty();
|
||
|
|
|
||
|
|
return bEnoughParams;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool UImportAssetsCommandlet::ParseImportSettings(const FString& InImportSettingsFile)
|
||
|
|
{
|
||
|
|
bool bInvalidParse = false;
|
||
|
|
bool bSuccess = false;
|
||
|
|
FString JsonString;
|
||
|
|
if (FFileHelper::LoadFileToString(JsonString, *InImportSettingsFile))
|
||
|
|
{
|
||
|
|
TSharedRef<TJsonReader<>> JsonReader = TJsonReaderFactory<>::Create(JsonString);
|
||
|
|
TSharedPtr<FJsonObject> RootObject;
|
||
|
|
if (FJsonSerializer::Deserialize(JsonReader, RootObject) && RootObject.IsValid())
|
||
|
|
{
|
||
|
|
const TArray<TSharedPtr<FJsonValue>> ImportGroupsJsonArray = RootObject->GetArrayField(TEXT("ImportGroups"));
|
||
|
|
for (const TSharedPtr<FJsonValue>& ImportGroupsJson: ImportGroupsJsonArray)
|
||
|
|
{
|
||
|
|
const TSharedPtr<FJsonObject> ImportGroupsJsonObject = ImportGroupsJson->AsObject();
|
||
|
|
if (ImportGroupsJsonObject.IsValid())
|
||
|
|
{
|
||
|
|
// All import data is based off of the global data defaults
|
||
|
|
UAutomatedAssetImportData* Data = DuplicateObject<UAutomatedAssetImportData>(GlobalImportData, this);
|
||
|
|
|
||
|
|
// Parse data from the json object
|
||
|
|
if (FJsonObjectConverter::JsonObjectToUStruct(ImportGroupsJsonObject.ToSharedRef(), UAutomatedAssetImportData::StaticClass(), Data, 0, 0))
|
||
|
|
{
|
||
|
|
Data->Initialize(ImportGroupsJsonObject);
|
||
|
|
if (Data->IsValid())
|
||
|
|
{
|
||
|
|
ImportDataList.Add(Data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bInvalidParse = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bInvalidParse = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Json settings file was found but was invalid: %s"), *JsonReader->GetErrorMessage());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Import settings file %s could not be found"), *InImportSettingsFile);
|
||
|
|
}
|
||
|
|
|
||
|
|
return bSuccess;
|
||
|
|
}
|
||
|
|
|
||
|
|
static bool SavePackage(UPackage* Package, const FString& PackageFilename)
|
||
|
|
{
|
||
|
|
return GEditor->SavePackage(Package, nullptr, RF_Standalone, *PackageFilename, GWarn);
|
||
|
|
}
|
||
|
|
|
||
|
|
bool UImportAssetsCommandlet::ImportAndSave(const TArray<UAutomatedAssetImportData*>& AssetImportList)
|
||
|
|
{
|
||
|
|
bool bImportAndSaveSucceeded = true;
|
||
|
|
|
||
|
|
FAssetToolsModule& AssetToolsModule = FModuleManager::Get().LoadModuleChecked<FAssetToolsModule>("AssetTools");
|
||
|
|
|
||
|
|
ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider();
|
||
|
|
|
||
|
|
for (const UAutomatedAssetImportData* ImportData: AssetImportList)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Log, TEXT("Importing group %s"), *ImportData->GetDisplayName());
|
||
|
|
|
||
|
|
UFactory* Factory = ImportData->Factory;
|
||
|
|
const TSharedPtr<FJsonObject>* ImportSettingsJsonObject = nullptr;
|
||
|
|
if (ImportData->ImportGroupJsonData.IsValid())
|
||
|
|
{
|
||
|
|
ImportData->ImportGroupJsonData->TryGetObjectField(TEXT("ImportSettings"), ImportSettingsJsonObject);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Factory != nullptr && ImportSettingsJsonObject)
|
||
|
|
{
|
||
|
|
IImportSettingsParser* ImportSettings = Factory->GetImportSettingsParser();
|
||
|
|
if (ImportSettings)
|
||
|
|
{
|
||
|
|
ImportSettings->ParseFromJson(ImportSettingsJsonObject->ToSharedRef());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else if (Factory == nullptr && ImportSettingsJsonObject)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Warning, TEXT("A vaild factory name must be specfied in order to specify settings"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load a level if specified
|
||
|
|
bImportAndSaveSucceeded = LoadLevel(ImportData->LevelToLoad);
|
||
|
|
|
||
|
|
// Clear dirty packages that were created as a result of loading the level. We do not want to save these
|
||
|
|
ClearDirtyPackages();
|
||
|
|
|
||
|
|
TArray<UObject*> ImportedAssets = AssetToolsModule.Get().ImportAssetsAutomated(ImportData);
|
||
|
|
if (ImportedAssets.Num() > 0 && bImportAndSaveSucceeded)
|
||
|
|
{
|
||
|
|
TArray<UPackage*> DirtyPackages;
|
||
|
|
|
||
|
|
TArray<FSourceControlStateRef> PackageStates;
|
||
|
|
|
||
|
|
FEditorFileUtils::GetDirtyContentPackages(DirtyPackages);
|
||
|
|
FEditorFileUtils::GetDirtyWorldPackages(DirtyPackages);
|
||
|
|
|
||
|
|
bool bUseSourceControl = bHasSourceControl && SourceControlProvider.IsAvailable();
|
||
|
|
if (bUseSourceControl)
|
||
|
|
{
|
||
|
|
SourceControlProvider.GetState(DirtyPackages, PackageStates, EStateCacheUsage::ForceUpdate);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (int32 PackageIndex = 0; PackageIndex < DirtyPackages.Num(); ++PackageIndex)
|
||
|
|
{
|
||
|
|
UPackage* PackageToSave = DirtyPackages[PackageIndex];
|
||
|
|
|
||
|
|
FString PackageFilename = SourceControlHelpers::PackageFilename(PackageToSave);
|
||
|
|
|
||
|
|
bool bShouldAttemptToSave = false;
|
||
|
|
bool bShouldAttemptToAdd = false;
|
||
|
|
if (bUseSourceControl)
|
||
|
|
{
|
||
|
|
FSourceControlStateRef PackageSCState = PackageStates[PackageIndex];
|
||
|
|
|
||
|
|
bool bPackageCanBeCheckedOut = false;
|
||
|
|
if (PackageSCState->IsCheckedOutOther())
|
||
|
|
{
|
||
|
|
// Cannot checkout, file is already checked out
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s is already checked out by someone else, can not submit!"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
else if (!PackageSCState->IsCurrent())
|
||
|
|
{
|
||
|
|
// Cannot checkout, file is not at head revision
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s is not at the head revision and cannot be checked out"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
else if (PackageSCState->CanCheckout())
|
||
|
|
{
|
||
|
|
const bool bWasCheckedOut = SourceControlHelpers::CheckOutOrAddFile(PackageFilename);
|
||
|
|
bShouldAttemptToSave = bWasCheckedOut;
|
||
|
|
if (!bWasCheckedOut)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s could not be checked out"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// package was not checked out by another user and is at the current head revision and could not be checked out
|
||
|
|
// this means it should be added after save because it doesn't exist
|
||
|
|
bShouldAttemptToSave = true;
|
||
|
|
bShouldAttemptToAdd = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bool bIsReadOnly = IFileManager::Get().IsReadOnly(*PackageFilename);
|
||
|
|
if (bIsReadOnly && ImportData->bSkipReadOnly)
|
||
|
|
{
|
||
|
|
bShouldAttemptToSave = false;
|
||
|
|
if (bIsReadOnly)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s is read only and -skipreadonly was specified. Will not save"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else if (bIsReadOnly)
|
||
|
|
{
|
||
|
|
bShouldAttemptToSave = FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*PackageFilename, false);
|
||
|
|
if (!bShouldAttemptToSave)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s is read only and could not be made writable. Will not save"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bShouldAttemptToSave = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (bShouldAttemptToSave)
|
||
|
|
{
|
||
|
|
SavePackage(PackageToSave, PackageFilename);
|
||
|
|
|
||
|
|
if (bShouldAttemptToAdd)
|
||
|
|
{
|
||
|
|
const bool bWasAdded = SourceControlHelpers::MarkFileForAdd(PackageFilename);
|
||
|
|
if (!bWasAdded)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("%s could not be added to source control"), *PackageFilename);
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bImportAndSaveSucceeded = false;
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Failed to import all assets in group %s"), *ImportData->GetDisplayName());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return bImportAndSaveSucceeded;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool UImportAssetsCommandlet::LoadLevel(const FString& LevelToLoad)
|
||
|
|
{
|
||
|
|
bool bResult = false;
|
||
|
|
|
||
|
|
if (!LevelToLoad.IsEmpty())
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Log, TEXT("Loading Map %s"), *LevelToLoad);
|
||
|
|
|
||
|
|
FString Filename;
|
||
|
|
if (FPackageName::TryConvertLongPackageNameToFilename(LevelToLoad, Filename))
|
||
|
|
{
|
||
|
|
UPackage* Package = LoadPackage(NULL, *Filename, 0);
|
||
|
|
|
||
|
|
UWorld* World = UWorld::FindWorldInPackage(Package);
|
||
|
|
if (World)
|
||
|
|
{
|
||
|
|
// Clean up any previous world. The world should have already been saved
|
||
|
|
UWorld* ExistingWorld = GEditor->GetEditorWorldContext().World();
|
||
|
|
|
||
|
|
GEngine->DestroyWorldContext(ExistingWorld);
|
||
|
|
ExistingWorld->DestroyWorld(true, World);
|
||
|
|
|
||
|
|
GWorld = World;
|
||
|
|
|
||
|
|
World->WorldType = EWorldType::Editor;
|
||
|
|
|
||
|
|
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(World->WorldType);
|
||
|
|
WorldContext.SetCurrentWorld(World);
|
||
|
|
|
||
|
|
// add the world to the root set so that the garbage collection to delete replaced actors doesn't garbage collect the whole world
|
||
|
|
World->AddToRoot();
|
||
|
|
|
||
|
|
// initialize the levels in the world
|
||
|
|
World->InitWorld(UWorld::InitializationValues().AllowAudioPlayback(false));
|
||
|
|
World->GetWorldSettings()->PostEditChange();
|
||
|
|
World->UpdateWorldComponents(true, false);
|
||
|
|
|
||
|
|
bResult = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// a map was not specified, ignore
|
||
|
|
bResult = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!bResult)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Could not find or load level %s"), *LevelToLoad);
|
||
|
|
}
|
||
|
|
|
||
|
|
return bResult;
|
||
|
|
}
|
||
|
|
|
||
|
|
void UImportAssetsCommandlet::ClearDirtyPackages()
|
||
|
|
{
|
||
|
|
TArray<UPackage*> DirtyPackages;
|
||
|
|
FEditorFileUtils::GetDirtyContentPackages(DirtyPackages);
|
||
|
|
FEditorFileUtils::GetDirtyWorldPackages(DirtyPackages);
|
||
|
|
|
||
|
|
for (UPackage* Package: DirtyPackages)
|
||
|
|
{
|
||
|
|
Package->SetDirtyFlag(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int32 UImportAssetsCommandlet::Main(const FString& InParams)
|
||
|
|
{
|
||
|
|
bool bEnoughParams = ParseParams(InParams);
|
||
|
|
|
||
|
|
int32 Result = 0;
|
||
|
|
|
||
|
|
if (!bEnoughParams || bShowHelp)
|
||
|
|
{
|
||
|
|
PrintUsage();
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// Hack: A huge amount of packages are marked dirty on startup. This is normally prevented in editor but commandlets have special powers.
|
||
|
|
// We only want to save assets that were created or modified at import time so clear all existing ones now
|
||
|
|
ClearDirtyPackages();
|
||
|
|
|
||
|
|
if (bAllowSourceControl)
|
||
|
|
{
|
||
|
|
ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider();
|
||
|
|
SourceControlProvider.Init();
|
||
|
|
|
||
|
|
bHasSourceControl = SourceControlProvider.IsEnabled();
|
||
|
|
if (!bHasSourceControl)
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Could not connect to source control!"))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
bHasSourceControl = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!ImportSettingsPath.IsEmpty())
|
||
|
|
{
|
||
|
|
// Use settings file for importing assets
|
||
|
|
ParseImportSettings(ImportSettingsPath);
|
||
|
|
}
|
||
|
|
else if (GlobalImportData->IsValid())
|
||
|
|
{
|
||
|
|
// Use single import path
|
||
|
|
ImportDataList.Add(GlobalImportData);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!ImportAndSave(ImportDataList))
|
||
|
|
{
|
||
|
|
UE_LOG(LogAutomatedImport, Error, TEXT("Could not import all groups"));
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Result = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return Result;
|
||
|
|
}
|