From 241e660694139ffbc56f16098cdd12cfae9338c1 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Thu, 22 Feb 2024 09:52:59 +0100 Subject: [PATCH] #756: Support for creating translation from any page + change path logic --- CHANGELOG.md | 1 + l10n/bundle.l10n.json | 4 +- package.json | 7 +- src/commands/Folders.ts | 81 ++++++++++------- src/commands/StatusListener.ts | 6 +- src/commands/i18n.ts | 91 ++++++++++++++----- src/constants/context.ts | 2 +- .../components/Contents/ContentActions.tsx | 2 +- src/localization/localization.enum.ts | 12 ++- src/models/ContentFolder.ts | 3 + 10 files changed, 144 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72662878..05531c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#731](https://github.com/estruyf/vscode-front-matter/issues/731): Added the ability to map/unmap taxonomy to multiple pages at once - [#746](https://github.com/estruyf/vscode-front-matter/issues/746): Placeholder support added to to the `slug` field - [#749](https://github.com/estruyf/vscode-front-matter/issues/749): Ability to set your own filters on the content dashboard with the `frontMatter.content.filters` setting +- [[#756](https://github.com/estruyf/vscode-front-matter/issues/756): i18n/multilingual content support ### 🎨 Enhancements diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 6ddfaeab..ef4467aa 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -539,7 +539,9 @@ "commands.i18n.create.warning.noFile": "The file could not be retrieved.", "commands.i18n.create.warning.noContentType": "Content type could not be retrieved for the current file.", "commands.i18n.create.warning.noConfig": "No i18n configuration found.", - "commands.i18n.create.warning.notDefaultLocale": "The current file cannot be used for i18n content creation.", + "commands.i18n.create.error.noLocaleDefinition": "Could not retrieve the locale for the current file.", + "commands.i18n.create.error.noLocales": "Current file has been translated to all available languages.", + "commands.i18n.create.error.noContentFolder": "Could not define a content folder for the current file.", "commands.i18n.create.error.fileExists": "The i18n translation already exists.", "commands.i18n.create.success.created": "Created \"{0}\" i18n content file.", "commands.i18n.create.quickPick.title": "Create content for locale", diff --git a/package.json b/package.json index 1139039b..e1434546 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,8 @@ }, "additionalProperties": false, "required": [ - "locale" + "locale", + "path" ] }, "scope": "Content" @@ -2371,7 +2372,7 @@ { "command": "frontMatter.i18n.create", "group": "navigation@-127", - "when": "frontMatter:file:isValid && frontMatter:i18n:default" + "when": "frontMatter:file:isValid && frontMatter:i18n:enabled" }, { "command": "frontMatter.markup.options", @@ -2474,7 +2475,7 @@ }, { "command": "frontMatter.i18n.create", - "when": "frontMatter:i18n:default" + "when": "frontMatter:i18n:enabled" }, { "command": "frontMatter.collapseSections", diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 935e6cb3..2f64e188 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -293,31 +293,6 @@ export class Folders { if (crntFolderInfo) { folderInfo.push(crntFolderInfo); } - - // Process localization folders - if (folder.defaultLocale) { - const i18nConfig = folder.locales || Settings.get(SETTING_CONTENT_I18N); - if (i18nConfig) { - for (const i18n of i18nConfig) { - if (i18n.locale !== folder.defaultLocale && i18n.path) { - const i18nFolder = { - ...folder, - path: join(folder.path, i18n.path), - title: `${folder.title} (${i18n.title})` - } as ContentFolder; - - const crntFolderInfo = await Folders.getFilesByFolder( - i18nFolder, - supportedFiles, - limit - ); - if (crntFolderInfo) { - folderInfo.push(crntFolderInfo); - } - } - } - } - } } return folderInfo; @@ -333,11 +308,14 @@ export class Folders { public static get(): ContentFolder[] { const wsFolder = Folders.getWorkspaceFolder(); let folders: ContentFolder[] = Settings.get(SETTING_CONTENT_PAGE_FOLDERS) as ContentFolder[]; + const i18nSettings = Settings.get(SETTING_CONTENT_I18N); // Filter out folders without a path folders = folders.filter((f) => f.path); - const contentFolders = folders.map((folder) => { + const contentFolders: ContentFolder[] = []; + + folders.forEach((folder) => { if (!folder.title) { folder.title = basename(folder.path); } @@ -371,11 +349,52 @@ export class Folders { } } - return { - ...folder, - originalPath: folder.path, - path: folderPath - }; + // Check i18n + if (folder.defaultLocale && (folder.locales || i18nSettings)) { + const i18nConfig = + folder.locales && folder.locales.length > 0 ? folder.locales : i18nSettings; + + let defaultLocale; + let sourcePath = folderPath; + let localeFolders: ContentFolder[] = []; + + if (i18nConfig && i18nConfig.length > 0) { + for (const i18n of i18nConfig) { + if (i18n.locale === folder.defaultLocale) { + defaultLocale = i18n; + } else if (i18n.locale !== folder.defaultLocale && i18n.path) { + localeFolders.push({ + ...folder, + title: `${folder.title} (${i18n.title})`, + locale: i18n.locale, + localeSourcePath: sourcePath, + path: join(folderPath, i18n.path) + }); + } + } + } + + const defaultTitle = defaultLocale?.title + ? `${folder.title} (${defaultLocale.title})` + : folder.title; + contentFolders.push({ + ...folder, + title: defaultTitle, + locale: folder.defaultLocale, + originalPath: folder.path, + localeSourcePath: sourcePath, + path: join(folderPath, defaultLocale?.path || '') + }); + + contentFolders.push(...localeFolders); + } else { + contentFolders.push({ + ...folder, + locale: folder.defaultLocale, + originalPath: folder.path, + path: folderPath + }); + } }); return contentFolders.filter((folder) => folder !== null) as ContentFolder[]; diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 50effdc5..4968d044 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -44,8 +44,8 @@ export class StatusListener { commands.executeCommand('setContext', CONTEXT.isValidFile, true); // Check i18n - const isI18nDefault = await i18n.isDefaultLanguage(document.uri.fsPath); - commands.executeCommand('setContext', CONTEXT.isI18nDefault, isI18nDefault); + const isI18nEnabled = await i18n.isLocaleEnabled(document.uri.fsPath); + commands.executeCommand('setContext', CONTEXT.isI18nEnabled, isI18nEnabled); const article = editor ? ArticleHelper.getFrontMatter(editor) @@ -88,7 +88,7 @@ export class StatusListener { } } else { commands.executeCommand('setContext', CONTEXT.isValidFile, false); - commands.executeCommand('setContext', CONTEXT.isI18nDefault, false); + commands.executeCommand('setContext', CONTEXT.isI18nEnabled, false); const panel = PanelProvider.getInstance(); if (panel && panel.visible) { diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 2dfd6589..760be23d 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -65,6 +65,25 @@ export class i18n { return pageFolder.locales; } + /** + * Checks if the locale is enabled for the given file path. + * @param filePath - The file path to check. + * @returns A promise that resolves to a boolean indicating whether the locale is enabled or not. + */ + public static async isLocaleEnabled(filePath: string): Promise { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return false; + } + + const pageFolder = Folders.getPageFolderByFilePath(filePath); + if (!pageFolder || !pageFolder.locale) { + return false; + } + + return i18nSettings.some((i18n) => i18n.locale === pageFolder.locale); + } + /** * Checks if the given file path corresponds to the default language. * @param filePath - The file path to check. @@ -84,6 +103,10 @@ export class i18n { const fileInfo = await i18n.getFileInfo(filePath); if (pageFolder.path) { + if (pageFolder.locale) { + return pageFolder.locale === pageFolder.defaultLocale; + } + let pageFolderPath = parseWinPath(pageFolder.path); if (!pageFolderPath.endsWith('/')) { pageFolderPath += '/'; @@ -120,9 +143,10 @@ export class i18n { if ( pageFolder.path && + pageFolder.locale && parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).toLowerCase() ) { - return i18nSettings.find((i18n) => i18n.locale === pageFolder?.defaultLocale); + return i18nSettings.find((i18n) => i18n.locale === pageFolder?.locale); } } @@ -172,9 +196,9 @@ export class i18n { let pageFolder = Folders.getPageFolderByFilePath(filePath); const fileInfo = await i18n.getFileInfo(filePath); - if (pageFolder && pageFolder.defaultLocale) { + if (pageFolder && pageFolder.defaultLocale && pageFolder.localeSourcePath) { for (const i18n of i18nSettings) { - const translation = join(pageFolder.path, i18n.path || '', fileInfo.filename); + const translation = join(pageFolder.localeSourcePath, i18n.path || '', fileInfo.filename); if (await existsAsync(translation)) { translations[i18n.locale] = { locale: i18n, @@ -224,20 +248,40 @@ export class i18n { fileUri = Uri.file(fileUri); } + const pageFolder = Folders.getPageFolderByFilePath(fileUri.fsPath); + if (!pageFolder || !pageFolder.localeSourcePath) { + Notifications.error(l10n.t(LocalizationKey.commandsI18nCreateErrorNoContentFolder)); + return; + } + const i18nSettings = await i18n.getSettings(fileUri.fsPath); if (!i18nSettings) { Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); return; } - const isDefaultLanguage = await i18n.isDefaultLanguage(fileUri.fsPath); - if (!isDefaultLanguage) { - Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNotDefaultLocale)); + const sourceLocale = await i18n.getLocale(fileUri.fsPath); + if (!sourceLocale || !sourceLocale.locale) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateErrorNoLocaleDefinition)); + return; + } + + const translations = (await i18n.getTranslations(fileUri.fsPath)) || {}; + const targetLocales = i18nSettings.filter((i18nSetting) => { + return ( + i18nSetting.path && + i18nSetting.locale !== sourceLocale.locale && + !translations[i18nSetting.locale] + ); + }); + + if (targetLocales.length === 0) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateErrorNoLocales)); return; } const locale = await window.showQuickPick( - i18nSettings.filter((i18n) => i18n.path).map((i18n) => i18n.title || i18n.locale), + targetLocales.map((i18n) => i18n.title || i18n.locale), { title: l10n.t(LocalizationKey.commandsI18nCreateQuickPickTitle), placeHolder: l10n.t(LocalizationKey.commandsI18nCreateQuickPickPlaceHolder), @@ -249,10 +293,10 @@ export class i18n { return; } - const selectedI18n = i18nSettings.find( + const targetLocale = i18nSettings.find( (i18n) => i18n.title === locale || i18n.locale === locale ); - if (!selectedI18n || !selectedI18n.path) { + if (!targetLocale || !targetLocale.path) { Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); return; } @@ -280,7 +324,7 @@ export class i18n { pageBundleDir = join(parse(pageBundleDir).dir); } - const i18nDir = join(dir, selectedI18n.path, pageBundleDir); + const i18nDir = join(pageFolder.localeSourcePath, targetLocale.path, pageBundleDir); if (!(await existsAsync(i18nDir))) { await workspace.fs.createDirectory(Uri.file(i18nDir)); @@ -290,7 +334,8 @@ export class i18n { article, fileUri.fsPath, contentType, - selectedI18n, + sourceLocale, + targetLocale, i18nDir ); @@ -300,9 +345,8 @@ export class i18n { return; } - const sourceLocale = await i18n.getLocale(fileUri.fsPath); if (sourceLocale?.locale) { - article = await i18n.translate(article, sourceLocale, selectedI18n); + article = await i18n.translate(article, sourceLocale, targetLocale); } const newFileUri = Uri.file(newFilePath); @@ -318,7 +362,7 @@ export class i18n { Notifications.info( l10n.t( LocalizationKey.commandsI18nCreateSuccessCreated, - selectedI18n.title || selectedI18n.locale + sourceLocale.title || sourceLocale.locale ) ); } @@ -349,9 +393,9 @@ export class i18n { cancellable: false }, async () => { - const title = article.data.title; - const description = article.data.description; - const content = article.content; + const title = article.data.title || ''; + const description = article.data.description || ''; + const content = article.content || ''; try { const body = JSON.stringify({ @@ -368,7 +412,6 @@ export class i18n { Authorization: `DeepL-Auth-Key ${authKey}`, 'User-Agent': `FrontMatterCMS/${Extension.getInstance().version}`, 'Content-Type': 'application/json', - 'content-length': body.length.toString(), Accept: 'application/json' }, body @@ -383,9 +426,9 @@ export class i18n { throw new Error('DeepL: Invalid response'); } - article.data.title = data.translations[0].text; - article.data.description = data.translations[1].text; - article.content = data.translations[2].text; + article.data.title = article.data.title ? data.translations[0].text : ''; + article.data.description = article.data.description ? data.translations[1].text : ''; + article.content = article.content ? data.translations[2].text : ''; } catch (error) { Notifications.error(`${(error as Error).message}`); } @@ -460,7 +503,8 @@ export class i18n { * @param article - The parsed front matter of the article. * @param filePath - The path of the file containing the front matter. * @param contentType - The content type of the article. - * @param i18nConfig - The configuration for internationalization. + * @param sourceLocale - The source locale. + * @param targetLocale - The target locale. * @param i18nDir - The directory where the i18n files are located. * @returns A Promise that resolves to the updated parsed front matter. */ @@ -468,7 +512,8 @@ export class i18n { article: ParsedFrontMatter, filePath: string, contentType: IContentType, - i18nConfig: I18nConfig, + sourceLocale: I18nConfig, + targetLocale: I18nConfig, i18nDir: string ): Promise { const imageFields = ContentType.findFieldsByTypeDeep(contentType.fields, 'image'); diff --git a/src/constants/context.ts b/src/constants/context.ts index d169dcc7..e71ac57d 100644 --- a/src/constants/context.ts +++ b/src/constants/context.ts @@ -8,7 +8,7 @@ export const CONTEXT = { isValidFile: 'frontMatter:file:isValid', isDevelopment: 'frontMatter:isDevelopment', - isI18nDefault: 'frontMatter:i18n:default', + isI18nEnabled: 'frontMatter:i18n:enabled', hasViewModes: 'frontMatter:has:modes', diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index b4ae10e9..85bfdc06 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -230,7 +230,7 @@ export const ContentActions: React.FunctionComponent = ({ } { - locale && isDefaultLocale && ( + locale && ( runCommand(COMMAND_NAME.i18n.create)}> {l10n.t(LocalizationKey.dashboardContentsContentActionsTranslationsCreate)} diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 28cc2d66..dcd9c9da 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1725,9 +1725,17 @@ export enum LocalizationKey { */ commandsI18nCreateWarningNoConfig = 'commands.i18n.create.warning.noConfig', /** - * The current file cannot be used for i18n content creation. + * Could not retrieve the locale for the current file. */ - commandsI18nCreateWarningNotDefaultLocale = 'commands.i18n.create.warning.notDefaultLocale', + commandsI18nCreateErrorNoLocaleDefinition = 'commands.i18n.create.error.noLocaleDefinition', + /** + * Current file has been translated to all available languages. + */ + commandsI18nCreateErrorNoLocales = 'commands.i18n.create.error.noLocales', + /** + * Could not define a content folder for the current file. + */ + commandsI18nCreateErrorNoContentFolder = 'commands.i18n.create.error.noContentFolder', /** * The i18n translation already exists. */ diff --git a/src/models/ContentFolder.ts b/src/models/ContentFolder.ts index 88f00a1d..a39662e6 100644 --- a/src/models/ContentFolder.ts +++ b/src/models/ContentFolder.ts @@ -12,6 +12,9 @@ export interface ContentFolder { originalPath?: string; $schema?: string; extended?: boolean; + + locale?: string; + localeSourcePath?: string; defaultLocale?: string; locales: I18nConfig[]; }