diff --git a/package.json b/package.json index 0192872..5eb5705 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "html-webpack-plugin": "^2.30.1", "json-stable-stringify": "^1.0.1", "jsonpath": "^1.0.0", + "lodash": "^4.17.4", "make-runnable": "^1.3.6", "mkdirp": "^0.5.1", "moment-timezone": "^0.5.13", diff --git a/src/js/regions.js b/src/js/regions.js index edc2051..3eb6165 100644 --- a/src/js/regions.js +++ b/src/js/regions.js @@ -5,6 +5,20 @@ const splatoonRegions = [ { key: 'jp', name: 'Japan', demonym: 'Japanese' }, ]; +const languages = [ + { region: 'NA', language: 'en' }, + { region: 'NA', language: 'es-MX' }, + { region: 'NA', language: 'fr-CA' }, + { region: 'EU', language: 'en' }, + { region: 'EU', language: 'de' }, + { region: 'EU', language: 'nl' }, + { region: 'EU', language: 'fr' }, + { region: 'EU', language: 'it' }, + { region: 'EU', language: 'ru' }, + { region: 'EU', language: 'es' }, + { region: 'JP', language: 'ja' }, +]; + function getRegionByKey(key) { return splatoonRegions.find(r => r.key == key); } @@ -64,6 +78,7 @@ function detectSplatoonRegionFromLanguage(language) { module.exports = { splatoonRegions, + languages, getRegionByKey, detectSplatoonRegion, } diff --git a/src/updater/LocalizationProcessor.js b/src/updater/LocalizationProcessor.js new file mode 100644 index 0000000..68a73b7 --- /dev/null +++ b/src/updater/LocalizationProcessor.js @@ -0,0 +1,96 @@ +const path = require('path'); +const fs = require('fs'); +const mkdirp = require('mkdirp'); +const jsonpath = require('jsonpath'); +const _ = require('lodash'); +const { readJson, writeJson } = require('./utilities'); + +const localizationsPath = path.resolve('public/data/lang'); + +class LocalizationProcessor { + constructor(ruleset, languageInfo) { + this.ruleset = ruleset; + this.languageInfo = languageInfo; + + let entities = this.ruleset.entities; + this.entityExpressions = (Array.isArray(entities)) ? entities : [entities]; + + let values = this.ruleset.values; + this.valueExpressions = (Array.isArray(values)) ? values : [values]; + + this.readData(); + } + + getFilename() { + return `${localizationsPath}/${this.languageInfo.language}.json`; + } + + readData() { + if (fs.existsSync(this.getFilename())) + this.data = readJson(this.getFilename()); + else + this.data = {}; + } + + writeData() { + mkdirp(path.dirname(this.getFilename())); + writeJson(this.getFilename(), this.data); + } + + getExpression(id, valueKey) { + return [this.ruleset.name, id, valueKey]; + } + + readValue(id, valueKey) { + return _.get(this.data, this.getExpression(id, valueKey)); + } + + writeValue(id, valueKey, newValue) { + return _.setWith(this.data, this.getExpression(id, valueKey), newValue, Object); + } + + eachEntity(data, callback) { + for (let expression of this.entityExpressions) { + let entities = jsonpath.query(data, expression); + + for (let entity of entities) { + let result = callback(entity); + if (result === false) + return false; + } + } + } + + updateLocalizations(data) { + this.eachEntity(data, entity => this.updateLocalizationsForEntity(entity)); + } + + updateLocalizationsForEntity(entity) { + for (let valueKey of this.valueExpressions) { + let id = _.get(entity, this.ruleset.id); + let value = _.get(entity, valueKey); + + this.writeValue(id, valueKey, value); + } + + this.writeData(); + } + + hasLocalizations(data) { + let result = this.eachEntity(data, entity => this.hasLocalizationsForEntity(entity)); + return (result !== false); + } + + hasLocalizationsForEntity(entity) { + for (let valueKey of this.valueExpressions) { + let id = _.get(entity, this.ruleset.id); + + if (this.readValue(id, valueKey) === undefined) + return false; + } + + return true; + } +} + +module.exports = LocalizationProcessor; diff --git a/src/updater/update.js b/src/updater/update.js index 8a9f748..2c86088 100644 --- a/src/updater/update.js +++ b/src/updater/update.js @@ -2,47 +2,20 @@ require('./bootstrap'); const Updater = require('./updaters/Updater'); const SchedulesUpdater = require('./updaters/SchedulesUpdater'); +const CoopSchedulesUpdater = require('./updaters/CoopSchedulesUpdater'); +const TimelineUpdater = require('./updaters/TimelineUpdater'); const OriginalGearImageUpdater = require('./updaters/OriginalGearImageUpdater'); const FestivalsUpdater = require('./updaters/FestivalsUpdater'); const MerchandisesUpdater = require('./updaters/MerchandisesUpdater'); const updaters = [ - // Original gear images - new OriginalGearImageUpdater(), - - // Schedules + new OriginalGearImageUpdater, new SchedulesUpdater, - - // Co-op Schedules - new Updater({ - name: 'Co-op Schedules', - filename: 'coop-schedules.json', - request: (splatnet) => splatnet.getCoopSchedules(), - imagePaths: [ - '$..stage.image', - '$..weapons[*].image', - ], - }), - - // Timeline - new Updater({ - name: 'Timeline', - filename: 'timeline.json', - request: (splatnet) => splatnet.getTimeline(), - rootKeys: ['coop', 'weapon_availability'], - imagePaths: [ - '$.coop..gear.image', - '$.coop..gear.brand.image', - '$.weapon_availability..weapon.image', - '$.weapon_availability..weapon.special.image_a', - '$.weapon_availability..weapon.sub.image_a', - ], - }), - - // Festivals - new FestivalsUpdater, - - // Merchandises + new CoopSchedulesUpdater, + new TimelineUpdater, + new FestivalsUpdater('NA'), + new FestivalsUpdater('EU'), + new FestivalsUpdater('JP'), new MerchandisesUpdater, ]; diff --git a/src/updater/updaters/CoopSchedulesUpdater.js b/src/updater/updaters/CoopSchedulesUpdater.js new file mode 100644 index 0000000..fd5386b --- /dev/null +++ b/src/updater/updaters/CoopSchedulesUpdater.js @@ -0,0 +1,43 @@ +const Updater = require('./Updater'); + +class CoopSchedulesUpdater extends Updater { + constructor() { + super({ + name: 'Co-op Schedules', + filename: 'coop-schedules.json', + request: (splatnet) => splatnet.getCoopSchedules(), + imagePaths: [ + '$..stage.image', + '$..weapons[*].image', + ], + localization: [ + { + name: 'coop_stages', + entities: '$..stage', + id: 'image', // Unfortunately these don't have an ID, so we'll just match them by image URL + values: 'name', + }, + { + name: 'weapons', + entities: '$..weapons[?(@.id)]', + id: 'id', + values: 'name', + }, + { + name: 'weapon_subs', + entities: '$..weapons[?(@.id)].sub', + id: 'id', + values: 'name', + }, + { + name: 'weapon_specials', + entities: '$..weapons[?(@.id)].special', + id: 'id', + values: 'name', + }, + ], + }); + } +} + +module.exports = CoopSchedulesUpdater; diff --git a/src/updater/updaters/FestivalsUpdater.js b/src/updater/updaters/FestivalsUpdater.js index 559f908..87ef2a6 100644 --- a/src/updater/updaters/FestivalsUpdater.js +++ b/src/updater/updaters/FestivalsUpdater.js @@ -1,10 +1,15 @@ const Updater = require('./Updater'); -const SplatNet = require('../splatnet'); +const mkdirp = require('mkdirp'); +const fs = require('fs'); +const path = require('path'); +const _ = require('lodash'); +const { readJson, writeJson } = require('../utilities'); +const { languages } = require('../../js/regions'); class FestivalsUpdater extends Updater { - constructor() { + constructor(region) { super({ - name: 'Festivals', + name: `Festivals ${region}`, filename: 'festivals.json', request: (splatnet) => splatnet.getCombinedFestivals(), imagePaths: [ @@ -13,15 +18,41 @@ class FestivalsUpdater extends Updater { '$..images.panel', '$..special_stage.image', ], + localization: [ + { + name: 'festivals', + entities: '$.festivals[*]', + id: 'festival_id', + values: 'names', + }, + { + name: 'stages', + entities: '$..special_stage', + id: 'id', + values: 'name', + }, + ], }); + + this.region = region; } - async getData() { - return { - na: await this.options.request(new SplatNet('NA')), - eu: await this.options.request(new SplatNet('EU')), - jp: await this.options.request(new SplatNet('JP')), - } + writeFile(filename, regionData) { + mkdirp(path.dirname(filename)); + + // Load existing data since we only need to modify this region's data + let data = {}; + if (fs.existsSync(filename)) + data = readJson(filename); + + let region = this.region.toLowerCase(); + data[region] = JSON.parse(regionData); + writeJson(filename, data); + } + + getLanguages() { + // Return all languages for this region + return _.filter(languages, { region: this.region }); } } diff --git a/src/updater/updaters/MerchandisesUpdater.js b/src/updater/updaters/MerchandisesUpdater.js index b915261..bf61bde 100644 --- a/src/updater/updaters/MerchandisesUpdater.js +++ b/src/updater/updaters/MerchandisesUpdater.js @@ -15,6 +15,32 @@ class MerchandisesUpdater extends Updater { '$..gear.brand.frequent_skill.image', '$..skill.image', ], + localization: [ + { + name: 'gear', + entities: '$..gear', + id: 'id', + values: 'name', + }, + { + name: 'brands', + entities: '$..gear.brand', + id: 'id', + values: 'name', + }, + { + name: 'skills', + entities: '$..gear.brand.frequent_skill', + id: 'id', + values: 'name', + }, + { + name: 'skills', + entities: '$..skill', + id: 'id', + values: 'name', + }, + ], }); } diff --git a/src/updater/updaters/SchedulesUpdater.js b/src/updater/updaters/SchedulesUpdater.js index a66e100..8c02928 100644 --- a/src/updater/updaters/SchedulesUpdater.js +++ b/src/updater/updaters/SchedulesUpdater.js @@ -16,6 +16,29 @@ class SchedulesUpdater extends Updater { '$..stage_a.image', '$..stage_b.image', ], + localization: [ + { + name: 'stages', + entities: [ + '$..stage_a', + '$..stage_b', + ], + id: 'id', + values: 'name', + }, + { + name: 'game_modes', + entities: '$..game_mode', + id: 'key', + values: 'name', + }, + { + name: 'rules', + entities: '$..rule', + id: 'key', + values: ['name', 'multiline_name'], + }, + ], }); } diff --git a/src/updater/updaters/TimelineUpdater.js b/src/updater/updaters/TimelineUpdater.js new file mode 100644 index 0000000..3cba335 --- /dev/null +++ b/src/updater/updaters/TimelineUpdater.js @@ -0,0 +1,53 @@ +const Updater = require('./Updater'); + +class TimelineUpdater extends Updater { + constructor() { + super({ + name: 'Timeline', + filename: 'timeline.json', + request: (splatnet) => splatnet.getTimeline(), + rootKeys: ['coop', 'weapon_availability'], + imagePaths: [ + '$.coop..gear.image', + '$.coop..gear.brand.image', + '$.weapon_availability..weapon.image', + '$.weapon_availability..weapon.special.image_a', + '$.weapon_availability..weapon.sub.image_a', + ], + localization: [ + { + name: 'gear', + entities: '$.coop..gear', + id: 'id', + values: 'name', + }, + { + name: 'brands', + entities: '$.coop..gear.brand', + id: 'id', + values: 'name', + }, + { + name: 'weapons', + entities: '$.weapon_availability..weapon', + id: 'id', + values: 'name', + }, + { + name: 'weapon_subs', + entities: '$.weapon_availability..weapon.sub', + id: 'id', + values: 'name', + }, + { + name: 'weapon_specials', + entities: '$.weapon_availability..weapon.special', + id: 'id', + values: 'name', + }, + ], + }); + } +} + +module.exports = TimelineUpdater; diff --git a/src/updater/updaters/Updater.js b/src/updater/updaters/Updater.js index 1d94d02..0a255f5 100644 --- a/src/updater/updaters/Updater.js +++ b/src/updater/updaters/Updater.js @@ -1,9 +1,12 @@ const path = require('path'); const fs = require('fs'); const mkdirp = require('mkdirp'); +const _ = require('lodash'); const jsonpath = require('jsonpath'); const SplatNet = require('../splatnet'); const raven = require('raven'); +const { languages } = require('../../js/regions'); +const LocalizationProcessor = require('../LocalizationProcessor'); const dataPath = path.resolve('public/data'); const splatnetAssetPath = path.resolve('public/assets/splatnet'); @@ -16,12 +19,18 @@ class Updater { async update() { this.info('Updating data...'); + // Use the first language as the default + let languageInfo = this.getLanguages()[0]; + // Retrieve the data - let data = await this.handleRequest(this.getData()); + let data = await this.handleRequest(this.getData(languageInfo)); // Filter the root keys if necessary data = this.filterRootKeys(data); + // Update localizations + data = await this.updateLocalizations(data, languageInfo); + // Apply any other processing data = await this.processData(data); @@ -38,8 +47,8 @@ class Updater { this.info('Done.'); } - getData() { - let splatnet = new SplatNet; + getData({ region, language }) { + let splatnet = new SplatNet(region, language); return this.options.request(splatnet); } @@ -78,6 +87,50 @@ class Updater { return data; } + getLanguages() { + // Only return one entry per language + // (i.e., only return "region: NA language: en" and not "region: EU language: en") + return _.uniqBy(languages, 'language'); + } + + forEachLanguage(callback) { + for (let languageInfo of this.getLanguages()) + this.forEachRuleset(languageInfo, callback); + } + + forEachRuleset(languageInfo, callback) { + for (let ruleset of (this.options.localization)) { + let processor = new LocalizationProcessor(ruleset, languageInfo); + callback(processor, languageInfo); + } + } + + async updateLocalizations(data, initialLanguageInfo) { + if (this.options.localization) { + // Update localization data for the initial language + this.forEachRuleset(initialLanguageInfo, processor => processor.updateLocalizations(data)); + + // Do we need to retrieve data for any other languages? + let missingLanguages = []; + this.forEachLanguage((processor, languageInfo) => { + if (missingLanguages.indexOf(languageInfo) === -1) { + if (!processor.hasLocalizations(data)) + missingLanguages.push(languageInfo); + } + }); + + // Retrieve data for missing languages + for (let missingLanguageInfo of missingLanguages) { + this.info(`Retrieving localized data for region: ${missingLanguageInfo.region}, language: ${missingLanguageInfo.language}`); + let localData = await this.handleRequest(this.getData(missingLanguageInfo)); + localData = this.filterRootKeys(localData); + this.forEachRuleset(missingLanguageInfo, processor => processor.updateLocalizations(localData)); + } + } + + return data; + } + shouldIncludeRootValue(value) { if (!value) return false;