diff --git a/bin/cmds/app/build.js b/bin/cmds/app/build.js index ff3078d4..ed8a4ef5 100644 --- a/bin/cmds/app/build.js +++ b/bin/cmds/app/build.js @@ -10,7 +10,7 @@ exports.handler = async yargs => { await app.build(); process.exit(0); } catch (err) { - Log.error(err); + Log.error(err.stack); process.exit(1); } }; diff --git a/bin/cmds/app/compose.js b/bin/cmds/app/migrate-compose.js similarity index 100% rename from bin/cmds/app/compose.js rename to bin/cmds/app/migrate-compose.js diff --git a/bin/cmds/app/migrate-locales.js b/bin/cmds/app/migrate-locales.js new file mode 100644 index 00000000..4df40486 --- /dev/null +++ b/bin/cmds/app/migrate-locales.js @@ -0,0 +1,16 @@ +'use strict'; + +const Log = require('../../../lib/Log'); +const App = require('../../../lib/App'); + +exports.desc = 'Migrate all i18n strings from separate files to a single ./homycompose/locales/.json'; +exports.handler = async yargs => { + try { + const app = new App(yargs.path); + await app.migrateLocales(); + process.exit(0); + } catch (err) { + Log.error(err); + process.exit(1); + } +}; diff --git a/lib/App.js b/lib/App.js index 466ba490..74621b02 100644 --- a/lib/App.js +++ b/lib/App.js @@ -2940,6 +2940,28 @@ $ sudo systemctl restart docker } } + async migrateLocales() { + // if (await GitCommands.isGitInstalled() && await GitCommands.isGitRepo({ path: this.path })) { + // if (await this._git.hasUncommittedChanges()) { + // const { shouldContinue } = await inquirer.prompt([ + // { + // type: 'confirm', + // name: 'shouldContinue', + // default: false, + // message: 'There are uncommitted changes. Are you sure you want to continue?', + // }, + // ]); + // if (!shouldContinue) return; + // } + // } + + if (App.hasHomeyCompose({ appPath: this.path }) === false) { + throw new Error('This command requires Homey Compose. Run `homey app migrate-compose` to migrate.'); + } + + await HomeyCompose.migrateLocales({ appPath: this.path }); + } + static hasHomeyCompose({ appPath }) { const hasComposeFolder = fs.existsSync(path.join(appPath, '.homeycompose')); diff --git a/lib/HomeyCompose.js b/lib/HomeyCompose.js index 954ec14c..d3259e6a 100644 --- a/lib/HomeyCompose.js +++ b/lib/HomeyCompose.js @@ -20,6 +20,8 @@ /drivers//driver.settings.compose.json (array with driver settings, extend with "$extends": "")) /drivers//driver.flow.compose.json (object with flow cards, device arg is added automatically) + /drivers//driver.pair.compose.json (object with pair views) + /drivers//driver.repair.compose.json (object with repair views) /.homeycompose/locales/en.json /.homeycompose/locales/en.foo.json */ @@ -53,17 +55,21 @@ class HomeyCompose { await compose.run(); } + static async migrateLocales({ appPath }) { + const compose = new HomeyCompose(appPath); + await compose.migrateLocales(); + } + constructor(appPath) { this._appPath = appPath; + this._appPathCompose = path.join(this._appPath, '.homeycompose'); + this._appJsonPath = path.join(this._appPath, 'app.json'); + this._appJsonPathCompose = path.join(this._appPathCompose, 'app.json'); } async run() { - this._appPathCompose = path.join(this._appPath, '.homeycompose'); - - this._appJsonPath = path.join(this._appPath, 'app.json'); this._appJson = await this._getJsonFile(this._appJsonPath); - this._appJsonPathCompose = path.join(this._appPathCompose, 'app.json'); try { const appJSON = await this._getJsonFile(this._appJsonPathCompose); this._appJson = { @@ -175,6 +181,12 @@ class HomeyCompose { } // merge pair + try { + driverJson.pair = await this._getJsonFile(path.join(this._appPath, 'drivers', driverId, 'driver.pair.compose.json')); + } catch (err) { + if (err.code !== 'ENOENT') throw new Error(err); + } + if (Array.isArray(driverJson.pair)) { const appPairPath = path.join(this._appPath, 'drivers', driverId, 'pair'); const composePairPath = path.join(this._appPathCompose, 'drivers', 'pair'); @@ -225,6 +237,12 @@ class HomeyCompose { } // merge repair + try { + driverJson.repair = await this._getJsonFile(path.join(this._appPath, 'drivers', driverId, 'driver.repair.compose.json')); + } catch (err) { + if (err.code !== 'ENOENT') throw new Error(err); + } + if (Array.isArray(driverJson.repair)) { const appRepairPath = path.join(this._appPath, 'drivers', driverId, 'repair'); const composeRepairPath = path.join(this._appPathCompose, 'drivers', 'repair'); @@ -379,7 +397,8 @@ class HomeyCompose { obj[key] = obj[key].replace(new RegExp(`{{driverName${locale.charAt(0).toUpperCase()}${locale.charAt(1).toLowerCase()}}}`, 'g'), replacement); } } catch (err) { - throw new Error(`Missing property \`name\` in driver ${driverId}`); + console.error(`Missing property \`name\` in driver ${driverId}`); + // throw new Error(`Missing property \`name\` in driver ${driverId}`); } obj[key] = obj[key].replace(/{{driverPath}}/g, `/drivers/${driverId}`); obj[key] = obj[key].replace(/{{driverAssetsPath}}/g, `/drivers/${driverId}/assets`); @@ -535,6 +554,165 @@ class HomeyCompose { } } + // Merge $drivers, $flow, $capabilities into /app.json + for (let i = 0; i < appLocalesChanged.length; i++) { + const appLocaleId = appLocalesChanged[i]; + const appLocale = appLocales[appLocaleId]; + + if (appLocale.$capabilities) { + for (const [capabilityId, capability] of Object.entries(appLocale.$capabilities)) { + if (!this._appJson.capabilities?.[capabilityId]) continue; + + if (capability.title) { + this._appJson.capabilities[capabilityId].title = this._appJson.capabilities[capabilityId].title ?? {}; + this._appJson.capabilities[capabilityId].title[appLocaleId] = capability.title; + } + + if (capability.units) { + this._appJson.capabilities[capabilityId].units = this._appJson.capabilities[capabilityId].units ?? {}; + this._appJson.capabilities[capabilityId].units[appLocaleId] = capability.units; + } + } + + delete appLocale.$capabilities; + } + + if (appLocale.$drivers) { + for (const [driverId, driver] of Object.entries(appLocale.$drivers)) { + const appJsonDriver = this._appJson.drivers.find(driver => driver.id === driverId); + if (!appJsonDriver) continue; + + if (driver.name) { + appJsonDriver.name = appJsonDriver.name ?? {}; + appJsonDriver.name[appLocaleId] = driver.name; + } + + ['pair', 'repair'].forEach(pairType => { + if (driver[pairType]) { + for (const [viewId, view] of Object.entries(driver[pairType])) { + const appJsonDriverView = appJsonDriver[pairType].find(view => view.id === viewId); + if (!appJsonDriverView) continue; + + if (view.options) { + for (const [key, value] of Object.entries(view.options)) { + appJsonDriverView.options = appJsonDriverView.options ?? {}; + appJsonDriverView.options[key] = appJsonDriverView.options[key] ?? {}; + appJsonDriverView.options[key][appLocaleId] = value; + } + } + } + } + }); + + if (driver.settings) { + // Flatten settings + const appJsonDriverSettingsFlat = appJsonDriver.settings.reduce((acc, setting) => { + acc[setting.id] = setting; + if (setting.children) { + setting.children.forEach(child => { + acc[child.id] = child; + }); + } + return acc; + }, {}); + + for (const [settingId, setting] of Object.entries(driver.settings)) { + if (!settingId) continue; + + const appJsonDriverSetting = appJsonDriverSettingsFlat[settingId]; + if (!appJsonDriverSetting) continue; + + if (setting.label) { + appJsonDriverSetting.label = appJsonDriverSetting.label ?? {}; + appJsonDriverSetting.label[appLocaleId] = setting.label; + } + + if (setting.hint) { + appJsonDriverSetting.hint = appJsonDriverSetting.hint ?? {}; + appJsonDriverSetting.hint[appLocaleId] = setting.hint; + } + + if (appJsonDriverSetting.type === 'dropdown' && setting.values) { + for (const [valueId, value] of Object.entries(setting.values)) { + const appJsonDriverSettingValue = appJsonDriverSetting.values.find(value => value.id === valueId); + if (!appJsonDriverSettingValue) continue; + + if (value.label) { + appJsonDriverSettingValue.label = appJsonDriverSettingValue.label ?? {}; + appJsonDriverSettingValue.label[appLocaleId] = value.label; + } + } + } + } + } + } + + delete appLocale.$drivers; + } + + if (appLocale.$flow) { + ['triggers', 'conditions', 'actions'].forEach(flowType => { + if (!appLocale.$flow[flowType]) return; + + for (const [cardId, card] of Object.entries(appLocale.$flow[flowType])) { + const appJsonFlowCard = this._appJson.flow?.[flowType]?.find(card => card.id === cardId); + if (!appJsonFlowCard) continue; + + if (card.title) { + appJsonFlowCard.title = appJsonFlowCard.title ?? {}; + appJsonFlowCard.title[appLocaleId] = card.title; + } + + if (card.titleFormatted) { + appJsonFlowCard.titleFormatted = appJsonFlowCard.titleFormatted ?? {}; + appJsonFlowCard.titleFormatted[appLocaleId] = card.titleFormatted; + } + + if (card.hint) { + appJsonFlowCard.hint = appJsonFlowCard.hint ?? {}; + appJsonFlowCard.hint[appLocaleId] = card.hint; + } + + if (card.args) { + for (const [argId, arg] of Object.entries(card.args)) { + const appJsonFlowCardArg = appJsonFlowCard.args.find(arg => arg.name === argId); + if (!appJsonFlowCardArg) continue; + + if (arg.title) { + appJsonFlowCardArg.title = appJsonFlowCardArg.title ?? {}; + appJsonFlowCardArg.title[appLocaleId] = arg.title; + } + + if (arg.placeholder) { + appJsonFlowCardArg.placeholder = appJsonFlowCardArg.placeholder ?? {}; + appJsonFlowCardArg.placeholder[appLocaleId] = arg.placeholder; + } + } + } + + if (card.tokens) { + for (const [tokenId, token] of Object.entries(card.tokens)) { + const appJsonFlowCardToken = appJsonFlowCard.tokens.find(token => token.name === tokenId); + if (!appJsonFlowCardToken) continue; + + if (token.title) { + appJsonFlowCardToken.title = appJsonFlowCardToken.title ?? {}; + appJsonFlowCardToken.title[appLocaleId] = token.title; + } + + if (token.example) { + appJsonFlowCardToken.example = appJsonFlowCardToken.example ?? {}; + appJsonFlowCardToken.example[appLocaleId] = token.example; + } + } + } + } + }); + + delete appLocale.$flow; + } + } + for (let i = 0; i < appLocalesChanged.length; i++) { const appLocaleId = appLocalesChanged[i]; const appLocale = appLocales[appLocaleId]; @@ -622,6 +800,195 @@ class HomeyCompose { return fileJson; } + async migrateLocales() { + const locales = await this._getJsonFiles(path.join(this._appPathCompose, 'locales')); + + // Capabilities + const capabilitiesPath = path.join(this._appPathCompose, 'capabilities'); + const capabilities = await this._getJsonFiles(capabilitiesPath); + for (const [capabilityId, capability] of Object.entries(capabilities)) { + for (const locale of HomeyLib.App.getLocales()) { + if (capability.title?.[locale]) { + objectPath.set(locales, `${locale}.$capabilities.${capabilityId}.title`, capability.title[locale]); + } + + if (capability.units?.[locale]) { + objectPath.set(locales, `${locale}.$capabilities.${capabilityId}.units`, capability.units[locale]); + } + } + + delete capability.title; + delete capability.units; + + await writeFileAsync(path.join(capabilitiesPath, `${capabilityId}.json`), JSON.stringify(capability, false, 2)); + } + + // Drivers + const driversPath = path.join(this._appPath, 'drivers'); + const drivers = await this._getChildFolders(driversPath); + for (const driverId of drivers) { + // driver.compose.json + const driverComposePath = path.join(driversPath, driverId, 'driver.compose.json'); + if (await fse.exists(driverComposePath)) { + const driverComposeJson = await this._getJsonFile(driverComposePath); + for (const locale of HomeyLib.App.getLocales()) { + if (driverComposeJson.name?.[locale]) { + objectPath.set(locales, `${locale}.$drivers.${driverId}.name`, driverComposeJson.name[locale]); + } + } + + delete driverComposeJson.name; + await writeFileAsync(driverComposePath, JSON.stringify(driverComposeJson, false, 2)); + } + + // driver.flow.compose.json + const driverFlowComposePath = path.join(driversPath, driverId, 'driver.flow.compose.json'); + if (await fse.exists(driverFlowComposePath)) { + const driverFlowComposeJson = await this._getJsonFile(driverFlowComposePath); + + for (const flowType of ['triggers', 'conditions', 'actions']) { + const flowCards = driverFlowComposeJson[flowType]; + if (!Array.isArray(flowCards)) continue; + + for (const flowCard of flowCards) { + // title + for (const locale of HomeyLib.App.getLocales()) { + if (flowCard.title?.[locale]) { + objectPath.set(locales, `${locale}.$flow.${flowType}.${flowCard.id}.title`, flowCard.title[locale]); + } + } + delete flowCard.title; + + // titleFormatted + for (const locale of HomeyLib.App.getLocales()) { + if (flowCard.titleFormatted?.[locale]) { + objectPath.set(locales, `${locale}.$flow.${flowType}.${flowCard.id}.titleFormatted`, flowCard.titleFormatted[locale]); + } + } + delete flowCard.titleFormatted; + + // hint + for (const locale of HomeyLib.App.getLocales()) { + if (flowCard.hint?.[locale]) { + objectPath.set(locales, `${locale}.$flow.${flowType}.${flowCard.id}.hint`, flowCard.hint[locale]); + } + } + delete flowCard.hint; + + // args + if (Array.isArray(flowCard.args)) { + for (const arg of flowCard.args) { + for (const locale of HomeyLib.App.getLocales()) { + if (arg.title?.[locale]) { + objectPath.set(locales, [locale, '$flow', flowType, flowCard.id, 'args', arg.name, 'title'], arg.title[locale]); + } + + if (arg.placeholder?.[locale]) { + objectPath.set(locales, [locale, '$flow', flowType, flowCard.id, 'args', arg.name, 'placeholder'], arg.placeholder[locale]); + } + } + + delete arg.title; + delete arg.placeholder; + } + } + + // tokens + if (Array.isArray(flowCard.tokens)) { + for (const token of flowCard.tokens) { + for (const locale of HomeyLib.App.getLocales()) { + if (token.title?.[locale]) { + objectPath.set(locales, [locale, '$flow', flowType, flowCard.id, 'tokens', token.name, 'title'], token.title[locale]); + } + + if (token.example?.[locale]) { + objectPath.set(locales, [locale, '$flow', flowType, flowCard.id, 'tokens', token.name, 'example'], token.example[locale]); + } + } + + delete token.title; + delete token.example; + } + } + } + } + + await writeFileAsync(driverFlowComposePath, JSON.stringify(driverFlowComposeJson, false, 2)); + + // driver.settings.compose.json + const driverSettingsComposePath = path.join(driversPath, driverId, 'driver.settings.compose.json'); + if (await fse.exists(driverSettingsComposePath)) { + const driverSettingsComposeJson = await this._getJsonFile(driverSettingsComposePath); + + // Flatten settings + const driverSettingsComposeJsonFlat = driverSettingsComposeJson.reduce((acc, setting, i) => { + if (!setting.id) { + Log.warning(`Missing Setting ID in driver ${driverId}.settings.${i} (${setting.type})`); + return acc; + } + + acc[setting.id] = setting; + if (setting.children) { + setting.children.forEach(child => { + acc[child.id] = child; + }); + } + return acc; + }, {}); + + for (const [settingId, setting] of Object.entries(driverSettingsComposeJsonFlat)) { + if (!settingId) continue; + + for (const locale of HomeyLib.App.getLocales()) { + if (setting.label?.[locale]) { + objectPath.set(locales, [locale, '$drivers', driverId, 'settings', settingId, 'label'], setting.label[locale]); + } + + if (setting.hint?.[locale]) { + objectPath.set(locales, [locale, '$drivers', driverId, 'settings', settingId, 'hint'], setting.hint[locale]); + } + + if (setting.values) { + for (const [valueId, value] of Object.entries(setting.values)) { + if (value.label?.[locale]) { + objectPath.set(locales, [locale, '$drivers', driverId, 'settings', settingId, 'values', valueId, 'label'], value.label[locale]); + } + } + } + } + + delete setting.label; + delete setting.hint; + + if (setting.values) { + for (const value of setting.values) { + delete value.label; + } + } + + await writeFileAsync(driverSettingsComposePath, JSON.stringify(driverSettingsComposeJson, false, 2)); + } + } + + // driver.pair.compose.json + // TODO + + // driver.repair.compose.json + // TODO + } + + // Flow + const flowPath = path.join(this._appPathCompose, 'flow'); + // TODO + } + + // Write new Locales + for (const [localeId, locale] of Object.entries(locales)) { + await fse.ensureDir(path.join(this._appPathCompose, 'locales')); + await writeFileAsync(path.join(this._appPathCompose, 'locales', `${localeId}.json`), JSON.stringify(locale, false, 2)); + } + } + } module.exports = HomeyCompose;