Initial implementation of Gulp based mission builder
This commit is contained in:
parent
f11b69463f
commit
b6a18f94fa
|
@ -0,0 +1,84 @@
|
||||||
|
# KP Liberation builder
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
nodejs version >=7.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run mission build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# Run task with local gulp via npx
|
||||||
|
npx gulp <task_name>
|
||||||
|
|
||||||
|
# With gulp-cli and gulp 4 installed globally
|
||||||
|
gulp <task_name>
|
||||||
|
|
||||||
|
```
|
||||||
|
| Task | Desc |
|
||||||
|
| ----------- | ---------------------------------------------- |
|
||||||
|
| clean | removes `build/` dir |
|
||||||
|
| build | assembles missionfolder and sets config values |
|
||||||
|
| pbo | packs missionfolders into PBOs |
|
||||||
|
| zip | creates release ZIPs |
|
||||||
|
| __default__ | runs _build_, _pbo_ and _zip_ |
|
||||||
|
|
||||||
|
Build files will be outputted to `build/` dir.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### presets.json
|
||||||
|
|
||||||
|
This file should contain an JSON __array__ of `Presets`, for every preset one mission file will be built.
|
||||||
|
|
||||||
|
Every `Preset` entry should have following structure:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
// Source folder with mission.sqm, relative to <missionsFolder>
|
||||||
|
// If mission.sqm is in root of <missionsFolder> should be set to empty string
|
||||||
|
"sourceFolder": "kp_liberation.Altis",
|
||||||
|
|
||||||
|
// Name and map is used to build output directory: <missionName>.<map>
|
||||||
|
// Name different than source allows to build multiple version of mission on same map
|
||||||
|
// Combination of <missionName> and <map> should be unique for every preset
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Altis",
|
||||||
|
|
||||||
|
// Keys of <variables> object represent variables in <configFile>.
|
||||||
|
// These variables values will be set to corresponding value in <variables>
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 0,
|
||||||
|
"KP_liberation_preset_opfor": 0,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### gulpfile.ts
|
||||||
|
|
||||||
|
`paths` variable in _gulpfile_ holds filesystem paths required to build missions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Mission folders configuration
|
||||||
|
*/
|
||||||
|
const paths: FolderStructureInfo = {
|
||||||
|
// Folder with mission scripts
|
||||||
|
frameworkFolder: resolve('..', 'Missionframework'),
|
||||||
|
|
||||||
|
// Folder with base mission.sqm folders
|
||||||
|
missionsFolder: resolve('..', 'Missionbasefiles'),
|
||||||
|
|
||||||
|
// Output directory
|
||||||
|
workDir: resolve("./build")
|
||||||
|
};
|
||||||
|
```
|
|
@ -0,0 +1,119 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Altis",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Altis",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 0,
|
||||||
|
"KP_liberation_preset_opfor": 0,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Chernarus",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Chernarus",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 6,
|
||||||
|
"KP_liberation_preset_opfor": 2,
|
||||||
|
"KP_liberation_preset_resistance": 2,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Malden",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Malden",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 0,
|
||||||
|
"KP_liberation_preset_opfor": 0,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Takistan",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Takistan",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 7,
|
||||||
|
"KP_liberation_preset_opfor": 3,
|
||||||
|
"KP_liberation_preset_resistance": 3,
|
||||||
|
"KP_liberation_preset_civilians": 2,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Takistan",
|
||||||
|
"missionName": "kp_liberation_afrf",
|
||||||
|
"map": "Takistan",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 8,
|
||||||
|
"KP_liberation_preset_opfor": 3,
|
||||||
|
"KP_liberation_preset_resistance": 3,
|
||||||
|
"KP_liberation_preset_civilians": 2,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.Sara",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "Sara",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 6,
|
||||||
|
"KP_liberation_preset_opfor": 2,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.xcam_taunus",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "xcam_taunus",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 4,
|
||||||
|
"KP_liberation_preset_opfor": 2,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.pja310",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "pja310",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 6,
|
||||||
|
"KP_liberation_preset_opfor": 2,
|
||||||
|
"KP_liberation_preset_resistance": 0,
|
||||||
|
"KP_liberation_preset_civilians": 0,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceFolder": "kp_liberation.lythium",
|
||||||
|
"missionName": "kp_liberation",
|
||||||
|
"map": "lythium",
|
||||||
|
"configFile": "kp_liberation_config.sqf",
|
||||||
|
"variables": {
|
||||||
|
"KP_liberation_preset_blufor": 7,
|
||||||
|
"KP_liberation_preset_opfor": 2,
|
||||||
|
"KP_liberation_preset_resistance": 3,
|
||||||
|
"KP_liberation_preset_civilians": 2,
|
||||||
|
"KP_liberation_arsenal": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
|
@ -0,0 +1,145 @@
|
||||||
|
import * as gulp from "gulp";
|
||||||
|
import * as gulpReplace from "gulp-replace";
|
||||||
|
import * as gulpPbo from "gulp-armapbo";
|
||||||
|
import * as gulpZip from "gulp-zip";
|
||||||
|
import * as vinylPaths from "vinyl-paths";
|
||||||
|
import * as del from "del";
|
||||||
|
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
import { MissionPaths } from "./src";
|
||||||
|
import { Preset, FolderStructureInfo } from "./src";
|
||||||
|
|
||||||
|
|
||||||
|
const presets: Preset[] = require('./_presets.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission folders configuration
|
||||||
|
*/
|
||||||
|
const paths: FolderStructureInfo = {
|
||||||
|
frameworkFolder: resolve('..', 'Missionframework'),
|
||||||
|
missionsFolder: resolve('..', 'Missionbasefiles'),
|
||||||
|
workDir: resolve("./build")
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create gulp tasks
|
||||||
|
*/
|
||||||
|
let taskNames: string[] = [];
|
||||||
|
let taskNamesPbo: string[] = [];
|
||||||
|
let taskNamesZip: string[] = [];
|
||||||
|
|
||||||
|
for (let preset of presets) {
|
||||||
|
const mission = new MissionPaths(preset, paths);
|
||||||
|
const taskName = [preset.missionName, preset.map].join('.');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy mission framework to output dir
|
||||||
|
*/
|
||||||
|
taskNames.push('framework_' + taskName);
|
||||||
|
|
||||||
|
gulp.task('framework_' + taskName, () => {
|
||||||
|
return gulp.src(mission.getFrameworkPath().concat('/**/*'))
|
||||||
|
.pipe(gulp.dest(mission.getOutputDir()));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy mission.sqm to output dir
|
||||||
|
*/
|
||||||
|
taskNames.push('sqm_' + taskName);
|
||||||
|
|
||||||
|
gulp.task('sqm_' + taskName, () => {
|
||||||
|
return gulp.src(mission.getMissionSqmPath())
|
||||||
|
.pipe(gulp.dest(mission.getOutputDir()));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace variables values in configuration file
|
||||||
|
*/
|
||||||
|
taskNames.push('vars_' + taskName);
|
||||||
|
|
||||||
|
gulp.task('vars_' + taskName, () => {
|
||||||
|
let src = gulp.src(mission.getMissionConfigFilePath());
|
||||||
|
|
||||||
|
const variables = Object.getOwnPropertyNames(preset.variables);
|
||||||
|
for (let variable of variables) {
|
||||||
|
// https://regex101.com/r/YknC8r/1
|
||||||
|
const regex = new RegExp(`(${variable} += +)(?:\\d+|".+")`, 'ig');
|
||||||
|
const value = JSON.stringify(preset.variables[variable]);
|
||||||
|
|
||||||
|
// replace variable value
|
||||||
|
src = src.pipe(gulpReplace(regex, `$1${value}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return src.pipe(gulp.dest(mission.getOutputDir()));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack PBOs
|
||||||
|
*/
|
||||||
|
taskNamesPbo.push('pack_' + taskName);
|
||||||
|
|
||||||
|
gulp.task('pack_' + taskName, () => {
|
||||||
|
return gulp.src(mission.getOutputDir() + '/**/*')
|
||||||
|
.pipe(gulpPbo({
|
||||||
|
fileName: mission.getFullName() + '.pbo',
|
||||||
|
progress: false,
|
||||||
|
verbose: false,
|
||||||
|
// Do not compress (SLOW)
|
||||||
|
compress: true ? [] : [
|
||||||
|
'**/*.sqf',
|
||||||
|
'mission.sqm',
|
||||||
|
'description.ext'
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.pipe(gulp.dest(mission.getWorkDir() + '/pbo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ZIP files
|
||||||
|
*/
|
||||||
|
taskNamesZip.push('zip_' + taskName);
|
||||||
|
|
||||||
|
gulp.task('zip_' + taskName, () => {
|
||||||
|
return gulp.src([
|
||||||
|
resolve('..', './userconfig/**/*'),
|
||||||
|
resolve('..', 'LICENSE.md'),
|
||||||
|
resolve('..', 'README.md')
|
||||||
|
], {
|
||||||
|
base: resolve('..') // Change base dir to have correct relative paths in ZIP
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
gulp.src(
|
||||||
|
resolve(mission.getWorkDir(), 'pbo', mission.getFullName() + '.pbo'), {
|
||||||
|
base: resolve(mission.getWorkDir(), 'pbo') // Change base dir to have correct relative paths in ZIP
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(gulpZip(
|
||||||
|
mission.getFullName() + '.zip'
|
||||||
|
))
|
||||||
|
.pipe(gulp.dest(mission.getWorkDir()))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main tasks
|
||||||
|
gulp.task('clean', () => {
|
||||||
|
return gulp.src(paths.workDir)
|
||||||
|
.pipe(vinylPaths(del));
|
||||||
|
});
|
||||||
|
|
||||||
|
gulp.task('build', gulp.series(taskNames));
|
||||||
|
|
||||||
|
gulp.task('pbo', gulp.series(taskNamesPbo));
|
||||||
|
|
||||||
|
gulp.task('zip', gulp.series(taskNamesZip));
|
||||||
|
|
||||||
|
gulp.task('default',
|
||||||
|
gulp.series(
|
||||||
|
gulp.task('build'),
|
||||||
|
gulp.task('pbo'),
|
||||||
|
gulp.task('zip'),
|
||||||
|
)
|
||||||
|
);
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
import { Preset, FolderStructureInfo } from "./src";
|
||||||
|
import { BuilderConfiguration } from "./src";
|
||||||
|
import { buildMission } from "./src";
|
||||||
|
|
||||||
|
|
||||||
|
const presets: Preset[] = require('./_presets.json');
|
||||||
|
|
||||||
|
const builderConf: BuilderConfiguration = {
|
||||||
|
outputDir: resolve("../build"),
|
||||||
|
async: false,
|
||||||
|
verbose: true,
|
||||||
|
zip: true,
|
||||||
|
pbo: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths: FolderStructureInfo = {
|
||||||
|
frameworkFolder: resolve('..', 'Missionframework'),
|
||||||
|
missionsFolder: resolve('..', 'Missionbasefiles', 'kp_liberation.'),
|
||||||
|
missionConfigFile: 'kp_liberation_config.sqf',
|
||||||
|
workDir: resolve(builderConf.outputDir)
|
||||||
|
};
|
||||||
|
|
||||||
|
buildMission(presets, paths, builderConf);
|
||||||
|
|
||||||
|
console.log();
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "kp_liberation",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/del": "^3.0.0",
|
||||||
|
"@types/gulp": "^4.0.5",
|
||||||
|
"@types/gulp-replace": "0.0.31",
|
||||||
|
"@types/gulp-zip": "^4.0.0",
|
||||||
|
"@types/vinyl-paths": "0.0.31",
|
||||||
|
"del": "^3.0.0",
|
||||||
|
"gulp": "^4.0.0",
|
||||||
|
"gulp-armapbo": "^1.1.3",
|
||||||
|
"gulp-replace": "^0.6.1",
|
||||||
|
"gulp-zip": "^4.1.0",
|
||||||
|
"smart-zip": "0.0.9",
|
||||||
|
"ts-node": "^4.1.0",
|
||||||
|
"typescript": "^2.7.1",
|
||||||
|
"vinyl-paths": "^2.1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npx gulp"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
export interface Preset {
|
||||||
|
/**
|
||||||
|
* Path to folder with mission.sqm relative to "missionsFolder".
|
||||||
|
* If mission.sqm is in root of "missionsFolder" should be empty string.
|
||||||
|
*
|
||||||
|
* @see FolderStructureInfo.missionsFolder
|
||||||
|
*/
|
||||||
|
readonly sourceFolder: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to file with mission configuration.
|
||||||
|
* Replacement of variables will be applied here.
|
||||||
|
*/
|
||||||
|
readonly configFile: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of mission (part before mapname)
|
||||||
|
*/
|
||||||
|
readonly missionName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map name
|
||||||
|
*/
|
||||||
|
readonly map: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key=>val of values to replace in config file
|
||||||
|
* @see {VariablesReplacements}
|
||||||
|
*/
|
||||||
|
readonly variables: VariablesReplacements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariablesReplacements {
|
||||||
|
/** Key should be name of variable as set in SQF file, its value will be replaced with one from entry. */
|
||||||
|
readonly [key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderStructureInfo {
|
||||||
|
/**
|
||||||
|
* Folder of folders with mission.sqm.
|
||||||
|
* Value of "sourceFolder" from Preset will be appended to this path.
|
||||||
|
*
|
||||||
|
* @see {Preset}
|
||||||
|
*/
|
||||||
|
readonly missionsFolder: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to folder with mission framework files.
|
||||||
|
*/
|
||||||
|
readonly frameworkFolder: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory containing built missions
|
||||||
|
*/
|
||||||
|
readonly workDir: string;
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Preset, FolderStructureInfo } from './Config';
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export class MissionPaths {
|
||||||
|
|
||||||
|
static readonly missionSQM = 'mission.sqm';
|
||||||
|
|
||||||
|
private preset: Preset;
|
||||||
|
|
||||||
|
private folderStructure: FolderStructureInfo;
|
||||||
|
|
||||||
|
constructor(preset: Preset, folderStructure: FolderStructureInfo) {
|
||||||
|
this.preset = preset;
|
||||||
|
this.folderStructure = folderStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMap(): string {
|
||||||
|
return this.preset.map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): string {
|
||||||
|
return this.preset.missionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFullName(): string {
|
||||||
|
return [this.getName(), this.getMap()].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public getWorkDir(): string {
|
||||||
|
return this.folderStructure.workDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to source mission.sqm file
|
||||||
|
*/
|
||||||
|
public getMissionSqmPath(): string {
|
||||||
|
return path.resolve(
|
||||||
|
this.folderStructure.missionsFolder,
|
||||||
|
this.preset.sourceFolder,
|
||||||
|
'mission.sqm'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to folder with mission framework files.
|
||||||
|
*/
|
||||||
|
public getFrameworkPath(): string {
|
||||||
|
return path.resolve(this.folderStructure.frameworkFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to folder containing mission files
|
||||||
|
*/
|
||||||
|
public getOutputDir(): string {
|
||||||
|
return path.resolve(
|
||||||
|
this.folderStructure.workDir,
|
||||||
|
this.getFullName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to file with mission configuration.
|
||||||
|
* As defined in preset.
|
||||||
|
*/
|
||||||
|
public getMissionConfigFilePath(): string {
|
||||||
|
return path.resolve(
|
||||||
|
this.getOutputDir(),
|
||||||
|
this.preset.configFile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
export * from "./MissionPaths";
|
||||||
|
|
||||||
|
export * from "./Config";
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es6",
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noImplicitAny": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src/"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue