@bscotch/stitch
Version:
Stitch: The GameMaker Studio 2 Asset Pipeline Development Kit.
227 lines • 10.7 kB
JavaScript
import { oneline, undent } from '@bscotch/utility';
import { differenceBy } from 'lodash-es';
import { assert, StitchError } from '../utility/errors.js';
import { debug, info, warn } from '../utility/log.js';
import paths from '../utility/paths.js';
import { Gms2IncludedFile } from './components/Gms2IncludedFile.js';
import { Gms2Sound } from './components/resources/Gms2Sound.js';
import { Gms2Sprite } from './components/resources/Gms2Sprite.js';
export class Gms2ProjectMerger {
sourceProject;
targetProject;
options;
constructor(sourceProject, targetProject, options) {
this.sourceProject = sourceProject;
this.targetProject = targetProject;
this.options = options || {};
this.options.onClobber ??= 'overwrite';
if (!this.options.ifFolderMatches && !this.options.ifNameMatches) {
// Then we aren't whitelisting.
this.options.ifFolderMatches = ['.*'];
}
}
async merge() {
const toImport = this.sourceProject.resources.filter((resource) => this.resourceMatchesOptions(resource));
if (!this.options.skipDependencyCheck) {
toImport.forEach((resource) => this.assertAllDependenciesFound(resource, toImport));
}
const targetResources = this.targetProject.resources.all;
debug(`Merging...`);
// See which target resources match the options pattern but are not in the source
for (const targetResource of targetResources) {
this.handleResourceConflict(targetResource, toImport);
}
// Import all resources matching the pattern.
for (const sourceResource of toImport) {
this.importResource(sourceResource);
}
if (!this.options.skipIncludedFiles) {
this.importIncludedFiles();
}
// Make sure any audio groups, texture pages, configs and other content referenced by new/updated
// resources actually exist.
await this.targetProject.ensureResourceGroupAssignments();
this.targetProject.resources.forEach((resource) => {
if (resource instanceof Gms2Sound) {
this.targetProject.addAudioGroup(resource.audioGroup);
}
else if (resource instanceof Gms2Sprite) {
this.targetProject.addTextureGroup(resource.textureGroup);
}
for (const configName of resource.configNames) {
this.targetProject.addConfig(configName);
}
});
this.targetProject.save();
debug(`Merge complete!`);
}
resourcesMatch(resource1, resource2, requireSameFolder = false) {
return (resource1.name == resource2.name &&
resource1.type == resource2.type &&
(!requireSameFolder || resource1.folder == resource2.folder));
}
assertAllDependenciesFound(resource, allResources) {
if (resource.type != 'objects') {
return false;
}
const object = resource;
if (object.parentName) {
const parentIsInModules = allResources.find((resource) => resource.type == 'objects' && resource.name == object.parentName);
assert(parentIsInModules, oneline `
Parent "${object.parentName}" for object "${object.name}" is not in the imported modules
`);
}
if (object.spriteName) {
const spriteIsInModules = allResources.find((resource) => resource.type == 'sprites' && resource.name == object.spriteName);
assert(spriteIsInModules, oneline `
Sprite "${object.spriteName}" for object "${object.name}" is not in the imported modules
`);
}
return;
}
resourceMatchesOptions(resource) {
if (!(resource instanceof Gms2IncludedFile) && this.options.types?.length) {
if (!this.options.types.includes(resource.type)) {
return false;
}
}
for (const matcher of this.options.ifFolderMatches || []) {
if (resource.folder.match(new RegExp(matcher, 'i'))) {
return true;
}
}
for (const matcher of this.options.ifNameMatches || []) {
if (resource.name.match(new RegExp(matcher, 'i'))) {
return true;
}
}
return false;
}
/**
* Move any target module assets into a folder called "MERGE_CONFLICTS"
* if they do not exist in the source module (if desired).
*/
handleResourceConflict(targetResource, toImport) {
const isExtra = this.resourceMatchesOptions(targetResource) && // Would be imported if in source
// But not in source
!toImport.find((sourceResource) => this.resourcesMatch(sourceResource, targetResource));
if (isExtra && this.options.moveConflicting) {
const conflictFolder = 'MERGE_CONFLICTS';
this.targetProject.addFolder(conflictFolder);
targetResource.folder = conflictFolder;
warn(`Moved conflicting asset "${targetResource.name}" into ${conflictFolder} folder`);
}
else if (isExtra) {
warn(oneline `
Target asset "${targetResource.name}" matches the merge pattern but is not in the source.
It was left alone. To have such resources moved, set the 'moveConflicting' option to 'true'.`);
}
}
importResource(sourceResource) {
// For each source asset:
// + See if there exists an asset with the same name ANYWHERE in the project
const matchingTarget = this.targetProject.resources.find((targetResource) => this.resourcesMatch(targetResource, sourceResource));
if (!matchingTarget) {
this.cloneResourceFiles(sourceResource);
this.targetProject.resources.register(sourceResource.toJSON(), this.targetProject.io);
info(`Added new resource ${sourceResource.name}`);
return;
}
// Else we're going to either overwrite or throw an error, depending on circumstances
// and options.
const warningMessage = oneline `
Local asset ${matchingTarget.name} exists but does not match merge pattern.
`;
const howToChangeMessage = oneline `
Rename the source or target asset, or set 'onClobber'
to 'overwrite' or 'error' to change this behavior.
`;
const matchesPattern = this.resourceMatchesOptions(matchingTarget);
if (matchesPattern) {
this.cloneResourceFiles(sourceResource);
}
else if (this.options.onClobber == 'skip') {
warn(oneline `
${warningMessage}
Import skipped (local version is unchanged).
${howToChangeMessage}
`);
return;
}
else if (this.options.onClobber == 'error') {
throw new StitchError(oneline `
${warningMessage} ${howToChangeMessage}
`);
}
else {
warn(oneline `
${warningMessage}
Import will occur anyway (the local asset will be replaced).
${howToChangeMessage}
`);
this.cloneResourceFiles(sourceResource);
}
}
importIncludedFiles() {
const sourceModuleFiles = this.sourceProject.includedFiles.filter((file) => this.resourceMatchesOptions(file));
// Loop over the sourcefiles and see if we can simply replace them in the target
for (const sourceModuleFile of sourceModuleFiles) {
let matchingTarget = this.targetProject.includedFiles.findByField('name', sourceModuleFile.name);
if (!matchingTarget) {
// Trim off the 'datafiles' parent folder
const subdir = sourceModuleFile.folder;
matchingTarget = this.targetProject.addIncludedFiles(sourceModuleFile.filePathAbsolute, { subdirectory: subdir })[0];
// Check the Config status of the source and match it to the target
// (including adding configs if they don't exist)
matchingTarget.config = sourceModuleFile.config;
for (const name of matchingTarget.configNames) {
this.targetProject.addConfig(name);
}
continue;
}
else if (this.resourceMatchesOptions(matchingTarget) ||
this.options.onClobber == 'overwrite') {
// Overwrite from source
matchingTarget.replaceWithFileContent(sourceModuleFile.filePathAbsolute);
if (!this.resourceMatchesOptions(matchingTarget)) {
warn(oneline `
File ${matchingTarget.name} will be overwritten by the source file,
even though it does not match the merge pattern. Prevent this by
changing the filename in the source or target, or by setting
'onClobber' to 'error' or 'skip'.
`);
}
continue;
}
else if (this.options.onClobber == 'error') {
// Exists but not in module: CONFLICT
throw new StitchError(oneline `
Conflict: local asset ${sourceModuleFile.name} exists,
but does not match the merge pattern. You can either skip
conflicting imports or allow them anyway by setting
'onclobber' to 'skip' or 'overwrite'.
`);
}
// else we're skipping, so can proceed to the next loop turn
}
if (this.options.moveConflicting) {
// If there are any files in the target module that are NOT in the source module,
// throw an error so the user can resolve this (it won't actually break anything,
// but is likely to create confusion downstream if not addressed)
const localModuleFiles = this.targetProject.includedFiles.filter((file) => this.resourceMatchesOptions(file));
const extraFiles = differenceBy(localModuleFiles, sourceModuleFiles, 'name');
if (extraFiles.length) {
throw new StitchError(undent `
CONFLICT: The following files were NOT in the source module but are in the target module.
${extraFiles.map((file) => file.name).join(', ')}
`);
}
}
}
cloneResourceFiles(sourceResource) {
this.targetProject.addFolder(sourceResource.folder);
const localYyDirAbsolute = paths.join(this.targetProject.storage.yypDirAbsolute, sourceResource.yyDirRelative);
this.targetProject.storage.copySync(sourceResource.yyDirAbsolute, localYyDirAbsolute);
}
}
//# sourceMappingURL=StitchProjectMerger.js.map