From 0428e561a816d76d9aba5014b171a32759cafd57 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Fri, 16 Feb 2024 17:23:08 +0100 Subject: [PATCH 1/8] #756 - first steps --- package.json | 362 ++++++++++++++++++------------------- src/commands/i18n.ts | 63 +++++++ src/constants/Extension.ts | 5 + src/constants/settings.ts | 1 + src/extension.ts | 6 +- src/models/i18nConfig.ts | 5 + src/models/index.ts | 1 + 7 files changed, 258 insertions(+), 185 deletions(-) create mode 100644 src/commands/i18n.ts create mode 100644 src/models/i18nConfig.ts diff --git a/package.json b/package.json index 5f9cafde..8a002aaf 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "color": "#0e131f", "theme": "dark" }, - "badges": [ - { + "badges": [{ "description": "version", "url": "https://img.shields.io/github/package-json/v/estruyf/vscode-front-matter?color=green&label=vscode-front-matter&style=flat-square", "href": "https://github.com/estruyf/vscode-front-matter" @@ -71,8 +70,7 @@ "**/.frontmatter/config/*.json": "jsonc" } }, - "keybindings": [ - { + "keybindings": [{ "command": "frontMatter.dashboard", "key": "alt+d" }, @@ -90,23 +88,19 @@ } ], "viewsContainers": { - "activitybar": [ - { - "id": "frontmatter-explorer", - "title": "FM", - "icon": "$(fm-logo)" - } - ] + "activitybar": [{ + "id": "frontmatter-explorer", + "title": "FM", + "icon": "$(fm-logo)" + }] }, "views": { - "frontmatter-explorer": [ - { - "id": "frontMatter.explorer", - "name": "Front Matter", - "icon": "$(fm-logo)", - "type": "webview" - } - ] + "frontmatter-explorer": [{ + "id": "frontMatter.explorer", + "name": "Front Matter", + "icon": "$(fm-logo)", + "type": "webview" + }] }, "configuration": { "title": "%settings.configuration.title%", @@ -174,8 +168,7 @@ "frontMatter.content.defaultFileType": { "type": "string", "default": "md", - "oneOf": [ - { + "oneOf": [{ "enum": [ "md", "mdx" @@ -191,8 +184,7 @@ "frontMatter.content.defaultSorting": { "type": "string", "default": "", - "oneOf": [ - { + "oneOf": [{ "enum": [ "LastModifiedAsc", "LastModifiedDesc", @@ -265,7 +257,7 @@ "markdownDescription": "%setting.frontMatter.content.hideFmMessage.markdownDescription%", "scope": "Content" }, - "frontMatter.content.pageFolders": { + "frontMatter.content.pageFolderscontent.pageFolders": { "type": "array", "default": [], "markdownDescription": "%setting.frontMatter.content.pageFolders.markdownDescription%", @@ -321,6 +313,34 @@ }, "scope": "Content" }, + "frontMatter.content.i18n": { + "type": "array", + "default": [], + "markdownDescription": "%setting.frontMatter.content.i18n.markdownDescription%", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "%setting.frontMatter.content.i18n.items.properties.title.description%" + }, + "locale": { + "type": "string", + "description": "%setting.frontMatter.content.i18n.items.properties.locale.description%" + }, + "path": { + "type": "string", + "description": "%setting.frontMatter.content.i18n.items.properties.path.description%" + } + }, + "additionalProperties": false, + "required": [ + "locale", + "path" + ] + }, + "scope": "Content" + }, "frontMatter.content.placeholders": { "type": "array", "default": [], @@ -500,8 +520,7 @@ "categories" ], "markdownDescription": "%setting.frontMatter.content.filters.markdownDescription%", - "items": [ - { + "items": [{ "type": "string" }, { @@ -569,8 +588,7 @@ "command": { "$id": "#scriptCommand", "type": "string", - "anyOf": [ - { + "anyOf": [{ "enum": [ "node", "bash", @@ -777,8 +795,7 @@ "title", "file" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -832,8 +849,7 @@ "id", "path" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -1074,29 +1090,26 @@ } } }, - "default": [ - { - "name": "default", - "fileTypes": null, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Caption", - "name": "caption", - "type": "string" - }, - { - "title": "Alt text", - "name": "alt", - "type": "string" - } - ] - } - ], + "default": [{ + "name": "default", + "fileTypes": null, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Caption", + "name": "caption", + "type": "string" + }, + { + "title": "Alt text", + "name": "alt", + "type": "string" + } + ] + }], "scope": "Media" }, "frontMatter.media.supportedMimeTypes": { @@ -1326,8 +1339,7 @@ "default": "", "description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1521,8 +1533,7 @@ "type", "name" ], - "allOf": [ - { + "allOf": [{ "if": { "properties": { "type": { @@ -1730,51 +1741,48 @@ "fields" ] }, - "default": [ - { - "name": "default", - "pageBundle": false, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Description", - "name": "description", - "type": "string" - }, - { - "title": "Publishing date", - "name": "date", - "type": "datetime", - "default": "{{now}}", - "isPublishDate": true - }, - { - "title": "Content preview", - "name": "preview", - "type": "image" - }, - { - "title": "Is in draft", - "name": "draft", - "type": "boolean" - }, - { - "title": "Tags", - "name": "tags", - "type": "tags" - }, - { - "title": "Categories", - "name": "categories", - "type": "categories" - } - ] - } - ], + "default": [{ + "name": "default", + "pageBundle": false, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Description", + "name": "description", + "type": "string" + }, + { + "title": "Publishing date", + "name": "date", + "type": "datetime", + "default": "{{now}}", + "isPublishDate": true + }, + { + "title": "Content preview", + "name": "preview", + "type": "image" + }, + { + "title": "Is in draft", + "name": "draft", + "type": "boolean" + }, + { + "title": "Tags", + "name": "tags", + "type": "tags" + }, + { + "title": "Categories", + "name": "categories", + "type": "categories" + } + ] + }], "scope": "Taxonomy" }, "frontMatter.taxonomy.customTaxonomy": { @@ -1787,8 +1795,7 @@ "type": "string", "description": "%setting.frontMatter.taxonomy.customTaxonomy.items.properties.id.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1985,8 +1992,7 @@ } } }, - "commands": [ - { + "commands": [{ "command": "frontMatter.project.switch", "title": "%command.frontMatter.project.switch%", "category": "Front Matter", @@ -2301,23 +2307,23 @@ "command": "frontMatter.cache.clear", "title": "%command.frontMatter.cache.clear%", "category": "Front Matter" - } - ], - "submenus": [ + }, { - "id": "frontmatter.submenu", - "label": "Front Matter" + "command": "frontMatter.i18n.create", + "title": "%command.frontMatter.i18n.create%", + "category": "Front Matter" } ], + "submenus": [{ + "id": "frontmatter.submenu", + "label": "Front Matter" + }], "menus": { - "webview/context": [ - { - "command": "workbench.action.webview.openDeveloperTools", - "when": "frontMatter:isDevelopment" - } - ], - "editor/title": [ - { + "webview/context": [{ + "command": "workbench.action.webview.openDeveloperTools", + "when": "frontMatter:isDevelopment" + }], + "editor/title": [{ "command": "frontMatter.markup.heading", "group": "navigation@-133", "when": "frontMatter:file:isValid == true && frontMatter:markdown:wysiwyg" @@ -2398,14 +2404,11 @@ "when": "resourceFilename == 'frontmatter.json'" } ], - "explorer/context": [ - { - "submenu": "frontmatter.submenu", - "group": "frontmatter@1" - } - ], - "frontmatter.submenu": [ - { + "explorer/context": [{ + "submenu": "frontmatter.submenu", + "group": "frontmatter@1" + }], + "frontmatter.submenu": [{ "command": "frontMatter.createFromTemplate", "when": "explorerResourceIsFolder", "group": "frontmatter@1" @@ -2421,8 +2424,7 @@ "group": "frontmatter@3" } ], - "commandPalette": [ - { + "commandPalette": [{ "command": "frontMatter.init", "when": "frontMatterCanInit" }, @@ -2571,8 +2573,7 @@ "when": "frontMatter:file:isValid == true" } ], - "view/title": [ - { + "view/title": [{ "command": "frontMatter.chatbot", "group": "navigation@0", "when": "view == frontMatter.explorer" @@ -2604,57 +2605,52 @@ } ] }, - "grammars": [ - { - "path": "./syntaxes/hugo.tmLanguage.json", - "scopeName": "frontmatter.markdown.hugo", - "injectTo": [ - "text.html.markdown" - ] - } - ], - "walkthroughs": [ - { - "id": "frontmatter.welcome", - "title": "Get started with Front Matter", - "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", - "steps": [ - { - "id": "frontmatter.welcome.init", - "title": "Get started", - "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", - "media": { - "markdown": "assets/walkthrough/get-started.md" - }, - "completionEvents": [ - "onContext:frontMatterInitialized" - ] + "grammars": [{ + "path": "./syntaxes/hugo.tmLanguage.json", + "scopeName": "frontmatter.markdown.hugo", + "injectTo": [ + "text.html.markdown" + ] + }], + "walkthroughs": [{ + "id": "frontmatter.welcome", + "title": "Get started with Front Matter", + "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", + "steps": [{ + "id": "frontmatter.welcome.init", + "title": "Get started", + "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", + "media": { + "markdown": "assets/walkthrough/get-started.md" }, - { - "id": "frontmatter.welcome.documentation", - "title": "Documentation", - "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", - "media": { - "markdown": "assets/walkthrough/documentation.md" - }, - "completionEvents": [ - "onLink:https://frontmatter.codes/docs" - ] + "completionEvents": [ + "onContext:frontMatterInitialized" + ] + }, + { + "id": "frontmatter.welcome.documentation", + "title": "Documentation", + "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", + "media": { + "markdown": "assets/walkthrough/documentation.md" }, - { - "id": "frontmatter.welcome.supporter", - "title": "Support the project", - "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", - "media": { - "markdown": "assets/walkthrough/support-the-project.md" - }, - "completionEvents": [ - "onLink:https://github.com/sponsors/estruyf" - ] - } - ] - } - ] + "completionEvents": [ + "onLink:https://frontmatter.codes/docs" + ] + }, + { + "id": "frontmatter.welcome.supporter", + "title": "Support the project", + "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", + "media": { + "markdown": "assets/walkthrough/support-the-project.md" + }, + "completionEvents": [ + "onLink:https://github.com/sponsors/estruyf" + ] + } + ] + }] }, "scripts": { "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", @@ -2783,4 +2779,4 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^2.0.6" } -} +} \ No newline at end of file diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts new file mode 100644 index 00000000..e333ab3c --- /dev/null +++ b/src/commands/i18n.ts @@ -0,0 +1,63 @@ +import { commands, window } from 'vscode'; +import { ArticleHelper, Extension, Notifications, Settings } from '../helpers'; +import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants'; +import { I18nConfig } from '../models'; + +export class i18n { + public static register() { + const subscriptions = Extension.getInstance().subscriptions; + + subscriptions.push(commands.registerCommand(COMMAND_NAME.i18n.create, i18n.create)); + } + + public static getSettings(): I18nConfig[] | undefined { + const i18nSettings = Settings.get(SETTING_CONTENT_I18N); + + if (!i18nSettings) { + return; + } + + return i18nSettings; + } + + private static async create(filePath?: string) { + const i18nSettings = i18n.getSettings(); + if (!i18nSettings) { + Notifications.warning('No i18n configuration found'); + return; + } + + if (!filePath) { + filePath = ArticleHelper.getActiveFile(); + } + + if (!filePath) { + Notifications.warning('No file selected'); + return; + } + + const locale = await window.showQuickPick( + i18nSettings.map((i18n) => i18n.title || i18n.locale), + { + title: 'Create content for locale', + placeHolder: 'To which locale do you want to create a new content?', + ignoreFocusOut: true + } + ); + + if (!locale) { + return; + } + + const selectedI18n = i18nSettings.find( + (i18n) => i18n.title === locale || i18n.locale === locale + ); + if (!selectedI18n) { + Notifications.warning('No i18n configuration found'); + return; + } + + // TODO: start from a page, or use a path + Notifications.info('Not implemented yet'); + } +} diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index 1111d40a..8c63e038 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -67,6 +67,11 @@ export const COMMAND_NAME = { addMissingFields: getCommandName('contenttype.addMissingFields'), setContentType: getCommandName('contenttype.setContentType'), + // i18n + i18n: { + create: getCommandName('i18n.create') + }, + // Project switchProject: getCommandName('project.switch'), diff --git a/src/constants/settings.ts b/src/constants/settings.ts index f5eff577..d184a65b 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -57,6 +57,7 @@ export const SETTING_CUSTOM_SCRIPTS = 'custom.scripts'; export const SETTING_AUTO_UPDATE_DATE = 'content.autoUpdateDate'; export const SETTING_CONTENT_PAGE_FOLDERS = 'content.pageFolders'; +export const SETTING_CONTENT_I18N = 'content.i18n'; export const SETTING_CONTENT_STATIC_FOLDER = 'content.publicFolder'; export const SETTING_CONTENT_FRONTMATTER_HIGHLIGHT = 'content.fmHighlight'; export const SETTING_CONTENT_DRAFT_FIELD = 'content.draftField'; diff --git a/src/extension.ts b/src/extension.ts index 4fe78498..44fbfa71 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,11 +13,10 @@ import { } from './helpers'; import ContentProvider from './providers/ContentProvider'; import { PagesListener } from './listeners/dashboard'; -import { NavigationType } from './dashboardWebView/models'; import { ModeSwitch } from './services/ModeSwitch'; import { PagesParser } from './services/PagesParser'; import { ContentType, Telemetry, Extension } from './helpers'; -import { TaxonomyType, DashboardData } from './models'; +import { TaxonomyType } from './models'; import * as l10n from '@vscode/l10n'; import { Backers, @@ -37,6 +36,7 @@ import { } from './commands'; import { join } from 'path'; import { Terminal } from './services'; +import { i18n } from './commands/i18n'; let pageUpdateDebouncer: { (fnc: any, time: number): void }; let editDebounce: { (fnc: any, time: number): void }; @@ -89,6 +89,8 @@ export async function activate(context: vscode.ExtensionContext) { Dashboard.init(); Dashboard.registerCommands(); + i18n.register(); + if (!extension.getVersion().usedVersion) { vscode.commands.executeCommand(COMMAND_NAME.dashboard); } diff --git a/src/models/i18nConfig.ts b/src/models/i18nConfig.ts new file mode 100644 index 00000000..033b9325 --- /dev/null +++ b/src/models/i18nConfig.ts @@ -0,0 +1,5 @@ +export interface I18nConfig { + title?: string; + locale: string; + path: string; +} diff --git a/src/models/index.ts b/src/models/index.ts index 226a9993..a118d63b 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -31,3 +31,4 @@ export * from './TaxonomyType'; export * from './Template'; export * from './UnmappedMedia'; export * from './VersionInfo'; +export * from './i18nConfig'; From 5f0fd29cca320db31d6b16d7389d23e9880231a0 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sun, 18 Feb 2024 16:12:01 +0100 Subject: [PATCH 2/8] #756 - i18n content creation --- assets/icons/i18n-dark.svg | 3 + assets/icons/i18n-light.svg | 3 + package.json | 24 +- package.nls.json | 2 + src/commands/Folders.ts | 171 ++++++--- src/commands/StatusListener.ts | 6 + src/commands/i18n.ts | 328 +++++++++++++++++- src/constants/context.ts | 2 + .../components/Contents/I18nLabel.tsx | 22 ++ .../components/Contents/Item.tsx | 35 +- src/dashboardWebView/models/Page.ts | 7 + src/helpers/ArticleHelper.ts | 42 ++- src/helpers/FrameworkDetector.ts | 54 +++ src/models/ContentFolder.ts | 4 + src/models/i18nConfig.ts | 4 +- src/services/PagesParser.ts | 9 + 16 files changed, 605 insertions(+), 111 deletions(-) create mode 100644 assets/icons/i18n-dark.svg create mode 100644 assets/icons/i18n-light.svg create mode 100644 src/dashboardWebView/components/Contents/I18nLabel.tsx diff --git a/assets/icons/i18n-dark.svg b/assets/icons/i18n-dark.svg new file mode 100644 index 00000000..9e723b9f --- /dev/null +++ b/assets/icons/i18n-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/i18n-light.svg b/assets/icons/i18n-light.svg new file mode 100644 index 00000000..65097821 --- /dev/null +++ b/assets/icons/i18n-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 8a002aaf..1498910c 100644 --- a/package.json +++ b/package.json @@ -257,7 +257,7 @@ "markdownDescription": "%setting.frontMatter.content.hideFmMessage.markdownDescription%", "scope": "Content" }, - "frontMatter.content.pageFolderscontent.pageFolders": { + "frontMatter.content.pageFolders": { "type": "array", "default": [], "markdownDescription": "%setting.frontMatter.content.pageFolders.markdownDescription%", @@ -303,6 +303,10 @@ "type": "boolean", "default": false, "description": "%setting.frontMatter.content.pageFolders.items.properties.disableCreation.description%" + }, + "defaultLocale": { + "type": "string", + "description": "%setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description%" } }, "additionalProperties": false, @@ -335,8 +339,7 @@ }, "additionalProperties": false, "required": [ - "locale", - "path" + "locale" ] }, "scope": "Content" @@ -2311,7 +2314,11 @@ { "command": "frontMatter.i18n.create", "title": "%command.frontMatter.i18n.create%", - "category": "Front Matter" + "category": "Front Matter", + "icon": { + "light": "assets/icons/i18n-light.svg", + "dark": "assets/icons/i18n-dark.svg" + } } ], "submenus": [{ @@ -2353,6 +2360,11 @@ "group": "navigation@-128", "when": "frontMatter:file:isValid == true" }, + { + "command": "frontMatter.i18n.create", + "group": "navigation@-127", + "when": "frontMatter:file:isValid && frontMatter:i18n:default" + }, { "command": "frontMatter.markup.options", "group": "navigation@-126", @@ -2452,6 +2464,10 @@ "command": "frontMatter.git.sync", "when": "frontMatter:git:enabled" }, + { + "command": "frontMatter.i18n.create", + "when": "frontMatter:i18n:default" + }, { "command": "frontMatter.collapseSections", "when": "false" diff --git a/package.nls.json b/package.nls.json index 3127bd29..736ca210 100644 --- a/package.nls.json +++ b/package.nls.json @@ -48,6 +48,7 @@ "command.frontMatter.markup.unorderedlist": "Unordered list", "command.frontMatter.git.sync": "Sync", "command.frontMatter.cache.clear": "Clear cache", + "command.frontMatter.i18n.create": "Create new translation", "settings.configuration.title": "Front Matter: use frontmatter.json for shared team settings", "setting.frontMatter.projects.markdownDescription": "Specify the list of projects to load in the Front Matter CMS. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.projects)", "setting.frontMatter.projects.items.properties.name.markdownDescription": "Specify the name of the project.", @@ -75,6 +76,7 @@ "setting.frontMatter.content.pageFolders.items.properties.filePrefix.description": "Defines a prefix for the file name.", "setting.frontMatter.content.pageFolders.items.properties.contentTypes.description": "Defines which content types can be used for the current location. If not defined, all content types will be available.", "setting.frontMatter.content.pageFolders.items.properties.disableCreation.description": "Disable the creation of new content in the folder.", + "setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description": "Set the page folder as a default locale for the content. All content from this folder is translatable to the languages defined in the `frontMatter.content.i18n` setting.", "setting.frontMatter.content.placeholders.markdownDescription": "This array of placeholders defines the placeholders that you can use in your content types and templates for automatically populating your content its front matter. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.content.placeholders)", "setting.frontMatter.content.placeholders.items.properties.id.description": "ID of the placeholder, in your content type or template, use it as follows: {{placeholder}}", "setting.frontMatter.content.placeholders.items.properties.value.description": "The placeholder its value", diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 7d1f4a16..935e6cb3 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -1,6 +1,7 @@ import { STATIC_FOLDER_PLACEHOLDER } from './../constants/StaticFolderPlaceholder'; import { Questions } from './../helpers/Questions'; import { + SETTING_CONTENT_I18N, SETTING_CONTENT_PAGE_FOLDERS, SETTING_CONTENT_STATIC_FOLDER, SETTING_CONTENT_SUPPORTED_FILETYPES, @@ -9,7 +10,7 @@ import { } from './../constants'; import { commands, Uri, workspace, window } from 'vscode'; import { basename, dirname, join, relative, sep } from 'path'; -import { ContentFolder, FileInfo, FolderInfo, StaticFolder } from '../models'; +import { ContentFolder, FileInfo, FolderInfo, I18nConfig, StaticFolder } from '../models'; import uniqBy = require('lodash.uniqby'); import { Template } from './Template'; import { Notifications } from '../helpers/Notifications'; @@ -288,67 +289,34 @@ export class Folders { const folderInfo: FolderInfo[] = []; for (const folder of folders) { - try { - const folderPath = parseWinPath(folder.path); + const crntFolderInfo = await Folders.getFilesByFolder(folder, supportedFiles, limit); + if (crntFolderInfo) { + folderInfo.push(crntFolderInfo); + } - if (typeof folderPath === 'string') { - let files: Uri[] = []; + // 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; - for (const fileType of supportedFiles || DEFAULT_FILE_TYPES) { - let filePath = join( - folderPath, - folder.excludeSubdir ? '/' : '**', - `*${fileType.startsWith('.') ? '' : '.'}${fileType}` - ); - - if (folderPath === '' && folder.excludeSubdir) { - filePath = `*${fileType.startsWith('.') ? '' : '.'}${fileType}`; - } - - let foundFiles = await Folders.findFiles(filePath); - - // Make sure these file are coming from the folder path (this could be an issue in multi-root workspaces) - foundFiles = foundFiles.filter((f) => parseWinPath(f.fsPath).startsWith(folderPath)); - - files = [...files, ...foundFiles]; - } - - if (files) { - let fileStats: FileInfo[] = []; - - for (const file of files) { - try { - const fileName = basename(file.fsPath); - const folderName = dirname(file.fsPath).split(sep).pop(); - - const stats = await workspace.fs.stat(file); - - fileStats.push({ - filePath: file.fsPath, - fileName, - folderName, - ...stats - }); - } catch (error) { - // Skip the file + const crntFolderInfo = await Folders.getFilesByFolder( + i18nFolder, + supportedFiles, + limit + ); + if (crntFolderInfo) { + folderInfo.push(crntFolderInfo); } } - - fileStats = fileStats.sort((a, b) => b.mtime - a.mtime); - - if (limit) { - fileStats = fileStats.slice(0, limit); - } - - folderInfo.push({ - title: folder.title, - files: files.length, - lastModified: fileStats - }); } } - } catch (e) { - // Skip the current folder } } @@ -603,6 +571,97 @@ export class Folders { return; } + /** + * Retrieves the page folder that matches the given file path. + * + * @param filePath - The file path to match against the page folders. + * @returns The page folder that matches the file path, or undefined if no match is found. + */ + public static getPageFolderByFilePath(filePath: string): ContentFolder | undefined { + const folders = Folders.get(); + const parsedPath = parseWinPath(filePath); + const pageFolderMatches = folders + .filter((folder) => parsedPath && folder.path && parsedPath.includes(folder.path)) + .sort((a, b) => b.path.length - a.path.length); + + if (pageFolderMatches.length > 0 && pageFolderMatches[0]) { + return pageFolderMatches[0]; + } + + return; + } + + private static async getFilesByFolder( + folder: ContentFolder, + supportedFiles: string[] | undefined, + limit?: number + ): Promise { + try { + const folderPath = parseWinPath(folder.path); + + if (typeof folderPath === 'string') { + let files: Uri[] = []; + + for (const fileType of supportedFiles || DEFAULT_FILE_TYPES) { + let filePath = join( + folderPath, + folder.excludeSubdir ? '/' : '**', + `*${fileType.startsWith('.') ? '' : '.'}${fileType}` + ); + + if (folderPath === '' && folder.excludeSubdir) { + filePath = `*${fileType.startsWith('.') ? '' : '.'}${fileType}`; + } + + let foundFiles = await Folders.findFiles(filePath); + + // Make sure these file are coming from the folder path (this could be an issue in multi-root workspaces) + foundFiles = foundFiles.filter((f) => parseWinPath(f.fsPath).startsWith(folderPath)); + + files = [...files, ...foundFiles]; + } + + if (files) { + let fileStats: FileInfo[] = []; + + for (const file of files) { + try { + const fileName = basename(file.fsPath); + const folderName = dirname(file.fsPath).split(sep).pop(); + + const stats = await workspace.fs.stat(file); + + fileStats.push({ + filePath: file.fsPath, + fileName, + folderName, + ...stats + }); + } catch (error) { + // Skip the file + } + } + + fileStats = fileStats.sort((a, b) => b.mtime - a.mtime); + + if (limit) { + fileStats = fileStats.slice(0, limit); + } + + return { + title: folder.title, + files: files.length, + lastModified: fileStats + }; + } + } + } catch (e) { + // Skip the current folder + } + + return; + } + /** * Retrieve all content folders * @param pattern diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index c665eac6..50effdc5 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -19,6 +19,7 @@ import { Field } from '../models'; import { Preview } from './Preview'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; +import { i18n } from './i18n'; export class StatusListener { /** @@ -42,6 +43,10 @@ export class StatusListener { try { commands.executeCommand('setContext', CONTEXT.isValidFile, true); + // Check i18n + const isI18nDefault = await i18n.isDefaultLanguage(document.uri.fsPath); + commands.executeCommand('setContext', CONTEXT.isI18nDefault, isI18nDefault); + const article = editor ? ArticleHelper.getFrontMatter(editor) : await ArticleHelper.getFrontMatterByPath(document.uri.fsPath); @@ -83,6 +88,7 @@ export class StatusListener { } } else { commands.executeCommand('setContext', CONTEXT.isValidFile, false); + commands.executeCommand('setContext', CONTEXT.isI18nDefault, false); const panel = PanelProvider.getInstance(); if (panel && panel.visible) { diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index e333ab3c..4128f1b8 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -1,43 +1,233 @@ -import { commands, window } from 'vscode'; -import { ArticleHelper, Extension, Notifications, Settings } from '../helpers'; +import { Uri, commands, window, workspace } from 'vscode'; +import { + ArticleHelper, + ContentType, + Extension, + FrameworkDetector, + Notifications, + Settings, + openFileInEditor, + parseWinPath +} from '../helpers'; import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants'; -import { I18nConfig } from '../models'; +import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models'; +import { join, parse } from 'path'; +import { existsAsync } from '../utils'; +import { Folders } from '.'; +import { ParsedFrontMatter } from '../parsers'; + +// TODO: +// Allow sponsors to automatically translate the content +// Support page bundles +// Filter on locale +// Locale settings on the page folder level and global level +// Show the i18n content -> if default locale is in subfolder, the other content is not found +// Update the page folder setting to include the locales property (use #ref) export class i18n { + /** + * Registers the i18n commands. + */ public static register() { const subscriptions = Extension.getInstance().subscriptions; subscriptions.push(commands.registerCommand(COMMAND_NAME.i18n.create, i18n.create)); } - public static getSettings(): I18nConfig[] | undefined { - const i18nSettings = Settings.get(SETTING_CONTENT_I18N); + /** + * Retrieves the I18nConfig settings from the application. + * @returns An array of I18nConfig objects if settings are found, otherwise undefined. + */ + public static async getSettings(filePath: string): Promise { + if (!filePath) { + return; + } + const i18nSettings = Settings.get(SETTING_CONTENT_I18N); + let pageFolder = Folders.getPageFolderByFilePath(filePath); + if (!pageFolder) { + const folders = Folders.get(); + + const localeFolders = folders?.filter((folder) => folder.defaultLocale); + if (!localeFolders) { + return; + } + + const fileName = parse(filePath).base; + for (const folder of localeFolders) { + const defaultFile = join(folder.path, fileName); + if (await existsAsync(defaultFile)) { + pageFolder = folder; + break; + } + } + } + + if (!pageFolder || !pageFolder.locales) { + return i18nSettings; + } + + return pageFolder.locales; + } + + /** + * Checks if the given file path corresponds to the default language. + * @param filePath - The file path to check. + * @returns True if the file path corresponds to the default language, false otherwise. + */ + public static async isDefaultLanguage(filePath: string): Promise { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return false; + } + + const pageFolder = Folders.getPageFolderByFilePath(filePath); + if (!pageFolder || !pageFolder.defaultLocale) { + return false; + } + + const fileInfo = parse(filePath); + const dir = fileInfo.dir; + + if (pageFolder.path) { + return parseWinPath(dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase(); + } + + return false; + } + + /** + * Retrieves the I18nConfig for a given file path. + * @param filePath - The path of the file. + * @returns The I18nConfig object if found, otherwise undefined. + */ + public static async getLocale(filePath: string): Promise { + const i18nSettings = await i18n.getSettings(filePath); if (!i18nSettings) { return; } - return i18nSettings; + const pageFolder = Folders.getPageFolderByFilePath(filePath); + const fileInfo = parse(filePath); + if (pageFolder && pageFolder.defaultLocale) { + if ( + pageFolder.path && + parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase() + ) { + return i18nSettings.find((i18n) => i18n.locale === pageFolder.defaultLocale); + } + } + + const folders = Folders.get(); + if (!folders) { + return; + } + + const fileName = fileInfo.base; + const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); + for (const folder of defaultLanguageFolders) { + for (const locale of i18nSettings) { + if (locale.path && folder.defaultLocale !== locale.locale) { + const translation = join(folder.path, locale.path, fileName); + if (parseWinPath(translation).toLowerCase() === parseWinPath(filePath).toLowerCase()) { + return locale; + } + } + } + } + + return; } - private static async create(filePath?: string) { - const i18nSettings = i18n.getSettings(); + /** + * Retrieves translations for a given file path. + * @param filePath - The path of the file for which translations are requested. + * @returns A promise that resolves to an object containing translations for each locale, or undefined if i18n settings are not available. + */ + public static async getTranslations( + filePath: string + ): Promise<{ [locale: string]: string } | undefined> { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return; + } + + const translations: { [locale: string]: string } = {}; + + const pageFolder = Folders.getPageFolderByFilePath(filePath); + if (pageFolder && pageFolder.defaultLocale) { + for (const i18n of i18nSettings) { + if (i18n.path) { + const translation = join(pageFolder.path, i18n.path, filePath); + if (await existsAsync(translation)) { + translations[i18n.locale] = translation; + } + } + } + return translations; + } + + const folders = Folders.get(); + if (!folders) { + return; + } + + const fileName = parse(filePath).base; + const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); + let defaultLanguageFolder: ContentFolder | undefined; + for (const folder of defaultLanguageFolders) { + const defaultFile = join(folder.path, fileName); + if (await existsAsync(defaultFile)) { + defaultLanguageFolder = folder; + break; + } + } + + if (!defaultLanguageFolder) { + return translations; + } + + for (const i18n of i18nSettings) { + const translation = join(defaultLanguageFolder.path, i18n.path || '', fileName); + if (await existsAsync(translation)) { + translations[i18n.locale] = translation; + } + } + + return translations; + } + + /** + * Creates a new content file for a specific locale based on the i18n configuration. + * If a file path is provided, the new content file will be created in the same directory. + * If no file path is provided, the active file in the editor will be used. + * @param filePath The path of the file where the new content file should be created. + */ + private static async create(fileUri?: Uri) { + if (!fileUri) { + const filePath = ArticleHelper.getActiveFile(); + fileUri = filePath ? Uri.file(filePath) : undefined; + } + + if (!fileUri) { + Notifications.warning('No file selected'); + return; + } + + const i18nSettings = await i18n.getSettings(fileUri.fsPath); if (!i18nSettings) { Notifications.warning('No i18n configuration found'); return; } - if (!filePath) { - filePath = ArticleHelper.getActiveFile(); - } - - if (!filePath) { - Notifications.warning('No file selected'); + const isDefaultLanguage = await i18n.isDefaultLanguage(fileUri.fsPath); + if (!isDefaultLanguage) { + Notifications.warning('The current file cannot be used for i18n content creation'); return; } const locale = await window.showQuickPick( - i18nSettings.map((i18n) => i18n.title || i18n.locale), + i18nSettings.filter((i18n) => i18n.path).map((i18n) => i18n.title || i18n.locale), { title: 'Create content for locale', placeHolder: 'To which locale do you want to create a new content?', @@ -52,12 +242,114 @@ export class i18n { const selectedI18n = i18nSettings.find( (i18n) => i18n.title === locale || i18n.locale === locale ); - if (!selectedI18n) { + if (!selectedI18n || !selectedI18n.path) { Notifications.warning('No i18n configuration found'); return; } - // TODO: start from a page, or use a path - Notifications.info('Not implemented yet'); + let article = await ArticleHelper.getFrontMatterByPath(fileUri.fsPath); + if (!article) { + Notifications.warning('No content found'); + return; + } + + const contentType = ArticleHelper.getContentType(article); + if (!contentType) { + Notifications.warning('No content type found'); + return; + } + + // Get the directory of the file + const fileInfo = parse(fileUri.fsPath); + const i18nDir = join(fileInfo.dir, selectedI18n.path); + + if (!(await existsAsync(i18nDir))) { + await workspace.fs.createDirectory(Uri.file(i18nDir)); + } + + article = await i18n.updateFrontMatter( + article, + fileUri.fsPath, + contentType, + selectedI18n, + i18nDir + ); + + const newFilePath = join(i18nDir, fileInfo.base); + if (await existsAsync(newFilePath)) { + Notifications.warning('File already exists'); + return; + } + + const newFileUri = Uri.file(newFilePath); + await workspace.fs.writeFile( + newFileUri, + Buffer.from(ArticleHelper.stringifyFrontMatter(article.content, article.data)) + ); + + await openFileInEditor(newFilePath); + + Notifications.info(`Created "${selectedI18n.title || selectedI18n.locale}" i18n content file`); + } + + /** + * Updates the front matter of an article with internationalization (i18n) support. + * + * @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 i18nDir - The directory where the i18n files are located. + * @returns A Promise that resolves to the updated parsed front matter. + */ + private static async updateFrontMatter( + article: ParsedFrontMatter, + filePath: string, + contentType: IContentType, + i18nConfig: I18nConfig, + i18nDir: string + ): Promise { + const imageFields = ContentType.findFieldsByTypeDeep(contentType.fields, 'image'); + if (imageFields.length > 0) { + article.data = await i18n.processImageFields(article.data, filePath, imageFields, i18nDir); + } + + return article; + } + + /** + * Processes the image fields in the provided data object. + * Replaces the image field values with the relative path to the image file. + * + * @param data - The data object containing the field values. + * @param filePath - The absolute file path of the data object. + * @param fields - The array of field arrays to process. + * @param i18nDir - The directory path for internationalization. + * @returns The updated data object with image field values replaced by relative paths. + */ + private static async processImageFields( + data: { [key: string]: any }, + filePath: string, + fields: Field[][], + i18nDir: string + ) { + for (const field of fields) { + if (!field) { + continue; + } + + for (const f of field) { + if (f.type === 'image') { + const value = data[f.name]; + if (value) { + let imgPath = FrameworkDetector.getAbsPathByFile(value, filePath); + imgPath = FrameworkDetector.getRelPathByFileDir(imgPath, i18nDir); + data[f.name] = imgPath; + } + } + } + } + + return data; } } diff --git a/src/constants/context.ts b/src/constants/context.ts index 9b68b2aa..d169dcc7 100644 --- a/src/constants/context.ts +++ b/src/constants/context.ts @@ -8,6 +8,8 @@ export const CONTEXT = { isValidFile: 'frontMatter:file:isValid', isDevelopment: 'frontMatter:isDevelopment', + isI18nDefault: 'frontMatter:i18n:default', + hasViewModes: 'frontMatter:has:modes', isSnippetsDashboardEnabled: 'frontMatter:dashboard:snippets:enabled', diff --git a/src/dashboardWebView/components/Contents/I18nLabel.tsx b/src/dashboardWebView/components/Contents/I18nLabel.tsx new file mode 100644 index 00000000..9861f9ab --- /dev/null +++ b/src/dashboardWebView/components/Contents/I18nLabel.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Page } from '../../models'; +import { LanguageIcon } from '@heroicons/react/24/outline'; + +export interface II18nLabelProps { + page: Page; +} + +export const I18nLabel: React.FunctionComponent = ({ + page +}: React.PropsWithChildren) => { + if (!page.fmLocale) { + return null; + } + + return ( +
+ + {page.fmLocale.title || page.fmLocale.locale} +
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 02ded451..5844e8ab 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -16,6 +16,7 @@ import { LocalizationKey } from '../../../localization'; import { useNavigate } from 'react-router-dom'; import { routePaths } from '../..'; import useCard from '../../hooks/useCard'; +import { I18nLabel } from './I18nLabel'; export interface IItemProps extends Page { } @@ -132,6 +133,8 @@ export const Item: React.FunctionComponent = ({ onOpen={openFile} /> + + - + { + (escapedDescription || descriptionHtml) && ( + + ) + } { tagsHtml ? ( diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index 2d20f3e0..dce8467d 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -1,3 +1,5 @@ +import { I18nConfig } from '../../models'; + export interface Page { // Properties for caching fmCachePath: string; @@ -19,6 +21,11 @@ export interface Page { fmContentType: string; fmDateFormat: string | undefined; + // i18n fields + fmDefaultLocale?: boolean; + fmLocale?: I18nConfig; + fmTranslations?: { [locale: string]: string }; + title: string; slug: string; date: string | Date; diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 03c8de9d..d3aa808f 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -123,8 +123,14 @@ export class ArticleHelper { * Retrieve the file's front matter by its path * @param filePath */ - public static async getFrontMatterByPath(filePath: string) { - const file = await readFileAsync(filePath, { encoding: 'utf-8' }); + public static async getFrontMatterByPath( + filePath: string + ): Promise { + const file = await ArticleHelper.getContents(filePath); + if (!file) { + return undefined; + } + const article = ArticleHelper.parseFile(file, filePath); if (!article) { return undefined; @@ -136,6 +142,20 @@ export class ArticleHelper { }; } + /** + * Reads the contents of a file asynchronously. + * @param filePath - The path of the file to read. + * @returns A promise that resolves to the contents of the file, or undefined if the file does not exist. + */ + public static async getContents(filePath: string): Promise { + const file = await workspace.fs.readFile(Uri.file(parseWinPath(filePath))); + if (!file) { + return undefined; + } + + return new TextDecoder().decode(file); + } + /** * Store the new information in the file * @@ -370,21 +390,9 @@ export class ArticleHelper { if (article.data.type) { contentType = contentTypes.find((ct) => ct.name === article.data.type); } else if (!contentType && article.path) { - // Get the content type by the folder name - let folders = Folders.get(); - let parsedPath = parseWinPath(article.path); - let pageFolderMatches = folders.filter( - (folder) => parsedPath && folder.path && parsedPath.includes(folder.path) - ); - - // Sort by longest path - pageFolderMatches = pageFolderMatches.sort((a, b) => b.path.length - a.path.length); - if ( - pageFolderMatches.length > 0 && - pageFolderMatches[0].contentTypes && - pageFolderMatches[0].contentTypes.length === 1 - ) { - const contentTypeName = pageFolderMatches[0].contentTypes[0]; + const pageFolder = Folders.getPageFolderByFilePath(article.path); + if (pageFolder && pageFolder.contentTypes?.length === 1) { + const contentTypeName = pageFolder.contentTypes[0]; contentType = contentTypes.find((ct) => ct.name === contentTypeName); } } diff --git a/src/helpers/FrameworkDetector.ts b/src/helpers/FrameworkDetector.ts index 0c98e7f8..dd44466e 100644 --- a/src/helpers/FrameworkDetector.ts +++ b/src/helpers/FrameworkDetector.ts @@ -151,6 +151,60 @@ export class FrameworkDetector { return parseWinPath(relAssetPath); } + /** + * Returns the absolute path by combining the relative path and the file path. + * If a static folder is configured, it will be taken into account. + * @param relAssetPath The relative path. + * @param filePath The file path. + * @returns The absolute path. + */ + public static getAbsPathByFile(relAssetPath: string, filePath: string): string { + const staticFolderValue = Settings.get(SETTING_CONTENT_STATIC_FOLDER); + const staticFolder = Folders.getStaticFolderRelativePath(); + + if ( + staticFolderValue && + staticFolder && + typeof staticFolderValue !== 'string' && + staticFolderValue.relative + ) { + const fileDir = dirname(filePath); + return parseWinPath(join(fileDir, relAssetPath)); + } + + return relAssetPath; + } + + /** + * Returns the relative path of an asset file based on the provided absolute asset path and file path. + * If the static folder setting is configured and the static folder is available, the relative path is calculated based on the file and asset directories. + * Otherwise, the absolute asset path is returned as is. + * + * @param absAssetPath The absolute path of the asset file. + * @param fileDir The path of the directory + * @returns The relative path of the asset file. + */ + public static getRelPathByFileDir(absAssetPath: string, fileDir: string): string { + const staticFolderValue = Settings.get(SETTING_CONTENT_STATIC_FOLDER); + const staticFolder = Folders.getStaticFolderRelativePath(); + + if ( + staticFolderValue && + staticFolder && + typeof staticFolderValue !== 'string' && + staticFolderValue.relative + ) { + const assetDir = dirname(absAssetPath); + const fileName = parse(absAssetPath); + + let relAssetPath = relative(fileDir, assetDir); + relAssetPath = join(relAssetPath, `${fileName.name}${fileName.ext}`); + return parseWinPath(relAssetPath); + } + + return absAssetPath; + } + /** * Define the default settings for Hexo */ diff --git a/src/models/ContentFolder.ts b/src/models/ContentFolder.ts index 734245a8..88f00a1d 100644 --- a/src/models/ContentFolder.ts +++ b/src/models/ContentFolder.ts @@ -1,3 +1,5 @@ +import { I18nConfig } from './i18nConfig'; + export interface ContentFolder { title: string; path: string; @@ -10,4 +12,6 @@ export interface ContentFolder { originalPath?: string; $schema?: string; extended?: boolean; + defaultLocale?: string; + locales: I18nConfig[]; } diff --git a/src/models/i18nConfig.ts b/src/models/i18nConfig.ts index 033b9325..1e472c1f 100644 --- a/src/models/i18nConfig.ts +++ b/src/models/i18nConfig.ts @@ -1,5 +1,5 @@ export interface I18nConfig { - title?: string; locale: string; - path: string; + title?: string; + path?: string; } diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index 06e95227..f573ebff 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -1,3 +1,4 @@ +import { i18n } from './../commands/i18n'; import { STATIC_FOLDER_PLACEHOLDER } from './../constants/StaticFolderPlaceholder'; import { parseWinPath } from './../helpers/parseWinPath'; import { dirname, extname, join } from 'path'; @@ -209,6 +210,10 @@ export class PagesParser { escapedDescription = ''; } + const isDefaultLanguage = await i18n.isDefaultLanguage(filePath); + const locale = await i18n.getLocale(filePath); + const translations = await i18n.getTranslations(filePath); + const page: Page = { ...article.data, // Cache properties @@ -230,6 +235,10 @@ export class PagesParser { fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME, fmBody: article?.content || '', fmDateFormat: dateFormat, + // i18n properties + fmDefaultLocale: isDefaultLanguage, + fmLocale: locale, + fmTranslations: translations, // Make sure these are always set title: escapedTitle, description: escapedDescription, From 36ac891c00d99a7c7997aa4463bae9094f4765df Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sun, 18 Feb 2024 21:20:07 +0100 Subject: [PATCH 3/8] #756 - Language dropdown --- src/commands/i18n.ts | 26 +++++++-- .../components/Contents/ContentActions.tsx | 2 +- .../components/Contents/I18nLabel.tsx | 55 ++++++++++++++++++- .../components/Contents/Item.tsx | 53 ++++++++++++------ src/dashboardWebView/hooks/usePages.tsx | 3 + src/dashboardWebView/models/Page.ts | 7 ++- 6 files changed, 118 insertions(+), 28 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 4128f1b8..e897bb1f 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -23,6 +23,8 @@ import { ParsedFrontMatter } from '../parsers'; // Locale settings on the page folder level and global level // Show the i18n content -> if default locale is in subfolder, the other content is not found // Update the page folder setting to include the locales property (use #ref) +// Update the default card item when the translation is removed +// Add action to create new translation export class i18n { /** @@ -146,21 +148,31 @@ export class i18n { */ public static async getTranslations( filePath: string - ): Promise<{ [locale: string]: string } | undefined> { + ): Promise<{ [locale: string]: { + locale: I18nConfig; + path: string; + } } | undefined> { const i18nSettings = await i18n.getSettings(filePath); if (!i18nSettings) { return; } - const translations: { [locale: string]: string } = {}; + const translations: { [locale: string]: { + locale: I18nConfig; + path: string; + } } = {}; const pageFolder = Folders.getPageFolderByFilePath(filePath); + const fileName = parse(filePath).base; if (pageFolder && pageFolder.defaultLocale) { for (const i18n of i18nSettings) { if (i18n.path) { - const translation = join(pageFolder.path, i18n.path, filePath); + const translation = join(pageFolder.path, i18n.path, fileName); if (await existsAsync(translation)) { - translations[i18n.locale] = translation; + translations[i18n.locale] = { + locale: i18n, + path: translation + }; } } } @@ -172,7 +184,6 @@ export class i18n { return; } - const fileName = parse(filePath).base; const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); let defaultLanguageFolder: ContentFolder | undefined; for (const folder of defaultLanguageFolders) { @@ -190,7 +201,10 @@ export class i18n { for (const i18n of i18nSettings) { const translation = join(defaultLanguageFolder.path, i18n.path || '', fileName); if (await existsAsync(translation)) { - translations[i18n.locale] = translation; + translations[i18n.locale] = { + locale: i18n, + path: translation + }; } } diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 1477e6af..5b2d1b2c 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -136,7 +136,7 @@ export const ContentActions: React.FunctionComponent = ({ )} - + {l10n.t(LocalizationKey.dashboardContentsContentActionsActionMenuButtonTitle)} diff --git a/src/dashboardWebView/components/Contents/I18nLabel.tsx b/src/dashboardWebView/components/Contents/I18nLabel.tsx index 9861f9ab..255315a9 100644 --- a/src/dashboardWebView/components/Contents/I18nLabel.tsx +++ b/src/dashboardWebView/components/Contents/I18nLabel.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import { Page } from '../../models'; -import { LanguageIcon } from '@heroicons/react/24/outline'; +import { ChevronDownIcon, LanguageIcon } from '@heroicons/react/24/outline'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../../components/shadcn/Dropdown'; +import { MenuItem } from '../Menu'; +import { DashboardMessage } from '../../DashboardMessage'; +import { messageHandler } from '@estruyf/vscode/dist/client'; export interface II18nLabelProps { page: Page; @@ -9,14 +13,59 @@ export interface II18nLabelProps { export const I18nLabel: React.FunctionComponent = ({ page }: React.PropsWithChildren) => { + + const openFile = (filePath: string) => { + messageHandler.send(DashboardMessage.openFile, filePath); + } + + const dropdown = React.useMemo(() => { + console.log(page) + if (!page.fmLocale || !page.fmTranslations || Object.keys(page.fmTranslations).length < 1) { + return null; + } + + return ( + + + + + + openFile(value)} /> + + + + { + Object.entries(page.fmTranslations).map(([key, value]) => { + return ( + openFile(value)} /> + ); + }) + } + + + ) + }, [page]) + if (!page.fmLocale) { return null; } return (
- - {page.fmLocale.title || page.fmLocale.locale} + {/* + {page.fmLocale.title || page.fmLocale.locale} */} + + {dropdown}
); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 5844e8ab..8cbc34f6 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -70,6 +70,34 @@ export const Item: React.FunctionComponent = ({ return []; }, [settings, pageData]); + const statusPlaceholder = useMemo(() => { + if (!statusHtml && !cardFields?.state) { + return null; + } + + return ( + statusHtml ? ( +
+ ) : ( + cardFields?.state && draftField && draftField.name && pageData[draftField.name] ? : null + ) + ) + }, [statusHtml, cardFields?.state, draftField, pageData]); + + const datePlaceholder = useMemo(() => { + if (!dateHtml && !cardFields?.date) { + return null; + } + + return ( + dateHtml ? ( +
+ ) : ( + cardFields?.date && pageData.date ? : null + ) + ) + }, [dateHtml, cardFields?.date, pageData]); + const hasDraftOrDate = useMemo(() => { return cardFields && (cardFields.state || cardFields.date); }, [cardFields]); @@ -107,23 +135,14 @@ export const Item: React.FunctionComponent = ({
-
- { - statusHtml ? ( -
- ) : ( - cardFields?.state && draftField && draftField.name && - ) - } - - { - dateHtml ? ( -
- ) : ( - cardFields?.date && - ) - } -
+ { + (statusPlaceholder || datePlaceholder) && ( +
+ {statusPlaceholder} + {datePlaceholder} +
+ ) + } !page.fmLocale || (page.fmLocale && page.fmDefaultLocale)) + // Process the tab data const draftTypes = Object.assign({}, tabInfo); draftTypes[Tab.All] = crntPages.length; diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index dce8467d..034bdb66 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -24,7 +24,12 @@ export interface Page { // i18n fields fmDefaultLocale?: boolean; fmLocale?: I18nConfig; - fmTranslations?: { [locale: string]: string }; + fmTranslations?: { + [locale: string]: { + locale: I18nConfig; + path: string; + } + }; title: string; slug: string; From 51ece235f8f2707c4c34c1f8c5686c0f99fc8e2a Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 19 Feb 2024 16:04:41 +0100 Subject: [PATCH 4/8] #756 - Language filter + card actions + submenu --- l10n/bundle.l10n.json | 15 ++++ package.json | 8 ++ package.nls.json | 5 ++ src/commands/i18n.ts | 88 ++++++++++++------- src/components/shadcn/Dropdown.tsx | 57 +----------- src/constants/GeneralCommands.ts | 1 + .../components/Contents/ContentActions.tsx | 87 ++++++++++++++++-- .../components/Contents/I18nLabel.tsx | 49 +---------- .../components/Contents/Item.tsx | 8 +- .../components/Filters/LanguageFilter.tsx | 66 ++++++++++++++ .../components/Header/ClearFilters.tsx | 12 ++- .../components/Header/Filter.tsx | 2 +- .../components/Header/Filters.tsx | 4 +- .../components/Media/ItemMenu.tsx | 5 +- .../components/Menu/MenuItem.tsx | 4 +- .../components/Menu/QuickAction.tsx | 5 +- src/dashboardWebView/hooks/usePages.tsx | 50 +++++++++-- src/dashboardWebView/state/atom/LocaleAtom.ts | 8 ++ .../state/atom/LocalesAtom.ts | 7 ++ src/dashboardWebView/state/atom/index.ts | 2 + src/listeners/general/BaseListener.ts | 8 +- src/localization/localization.enum.ts | 52 +++++++++++ 22 files changed, 384 insertions(+), 159 deletions(-) create mode 100644 src/dashboardWebView/components/Filters/LanguageFilter.tsx create mode 100644 src/dashboardWebView/state/atom/LocaleAtom.ts create mode 100644 src/dashboardWebView/state/atom/LocalesAtom.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index af9668d8..e20ccd28 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -90,6 +90,8 @@ "dashboard.contents.contentActions.menuItem.view": "View", "dashboard.contents.contentActions.alert.title": "Delete: {0}", "dashboard.contents.contentActions.alert.description": "Are you sure you want to delete the \"{0}\" content?", + "dashboard.contents.contentActions.translations.create": "Create translation", + "dashboard.contents.contentActions.translations.menu": "Translations", "dashboard.contents.item.invalidTitle": "", "dashboard.contents.item.invalidDescription": "", @@ -128,6 +130,9 @@ "dashboard.errorView.description": "Please close the dashboard and try again.", + "dashboard.filters.languageFilter.label": "Locale", + "dashboard.filters.languageFilter.all": "All", + "dashboard.header.breadcrumb.home": "Home", "dashboard.header.clearFilters.title": "Clear filters, grouping, and sorting", @@ -523,6 +528,16 @@ "commands.folders.get.notificationError.remove.action": "Remove folder", "commands.folders.get.notificationError.create.action": "Create folder", + "commands.i18n.create.warning.noFileSelected": "No file selected.", + "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.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", + "commands.i18n.create.quickPick.placeHolder": "To which locale do you want to create a new content?", + "commands.preview.panel.title": "Preview: {0}", "commands.preview.askUserToPickFolder.title": "Select the folder of the article to preview", diff --git a/package.json b/package.json index 1498910c..2e628580 100644 --- a/package.json +++ b/package.json @@ -307,6 +307,13 @@ "defaultLocale": { "type": "string", "description": "%setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description%" + }, + "locales": { + "type": "array", + "description": "%setting.frontMatter.content.pageFolders.items.properties.locales.description%", + "items": { + "$ref": "#i18n" + } } }, "additionalProperties": false, @@ -322,6 +329,7 @@ "default": [], "markdownDescription": "%setting.frontMatter.content.i18n.markdownDescription%", "items": { + "$id": "#i18n", "type": "object", "properties": { "title": { diff --git a/package.nls.json b/package.nls.json index 736ca210..597529d7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -77,6 +77,11 @@ "setting.frontMatter.content.pageFolders.items.properties.contentTypes.description": "Defines which content types can be used for the current location. If not defined, all content types will be available.", "setting.frontMatter.content.pageFolders.items.properties.disableCreation.description": "Disable the creation of new content in the folder.", "setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description": "Set the page folder as a default locale for the content. All content from this folder is translatable to the languages defined in the `frontMatter.content.i18n` setting.", + "setting.frontMatter.content.pageFolders.items.properties.locales.description": "Define the locales for the page folder. This will be used for the translation of the content.", + "setting.frontMatter.content.i18n.markdownDescription": "Specify the locales you want to use for your website. This setting can be overwritten on page folder level. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.content.i18n)", + "setting.frontMatter.content.i18n.items.properties.title.description": "Title of the locale", + "setting.frontMatter.content.i18n.items.properties.locale.description": "Locale code", + "setting.frontMatter.content.i18n.items.properties.path.description": "Relative path of the locale folder", "setting.frontMatter.content.placeholders.markdownDescription": "This array of placeholders defines the placeholders that you can use in your content types and templates for automatically populating your content its front matter. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.content.placeholders)", "setting.frontMatter.content.placeholders.items.properties.id.description": "ID of the placeholder, in your content type or template, use it as follows: {{placeholder}}", "setting.frontMatter.content.placeholders.items.properties.value.description": "The placeholder its value", diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index e897bb1f..ebd488bd 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -15,16 +15,22 @@ import { join, parse } from 'path'; import { existsAsync } from '../utils'; import { Folders } from '.'; import { ParsedFrontMatter } from '../parsers'; +import { PagesListener } from '../listeners/dashboard'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../localization'; // TODO: // Allow sponsors to automatically translate the content // Support page bundles -// Filter on locale +// Filter on locale ✅ // Locale settings on the page folder level and global level -// Show the i18n content -> if default locale is in subfolder, the other content is not found -// Update the page folder setting to include the locales property (use #ref) -// Update the default card item when the translation is removed -// Add action to create new translation +// Show the i18n content -> if default locale is in subfolder, the other content is not found ✅ +// Update the page folder setting to include the locales property (use #ref) ✅ +// Update the default card item when the translation is removed ✅ +// Add action to create new translation ✅ +// Trigger page update when translation is created ✅ +// Add translations to the menu ✅ +// Localization of the React components ✅ export class i18n { /** @@ -146,34 +152,37 @@ export class i18n { * @param filePath - The path of the file for which translations are requested. * @returns A promise that resolves to an object containing translations for each locale, or undefined if i18n settings are not available. */ - public static async getTranslations( - filePath: string - ): Promise<{ [locale: string]: { - locale: I18nConfig; - path: string; - } } | undefined> { + public static async getTranslations(filePath: string): Promise< + | { + [locale: string]: { + locale: I18nConfig; + path: string; + }; + } + | undefined + > { const i18nSettings = await i18n.getSettings(filePath); if (!i18nSettings) { return; } - const translations: { [locale: string]: { - locale: I18nConfig; - path: string; - } } = {}; + const translations: { + [locale: string]: { + locale: I18nConfig; + path: string; + }; + } = {}; const pageFolder = Folders.getPageFolderByFilePath(filePath); const fileName = parse(filePath).base; if (pageFolder && pageFolder.defaultLocale) { for (const i18n of i18nSettings) { - if (i18n.path) { - const translation = join(pageFolder.path, i18n.path, fileName); - if (await existsAsync(translation)) { - translations[i18n.locale] = { - locale: i18n, - path: translation - }; - } + const translation = join(pageFolder.path, i18n.path || '', fileName); + if (await existsAsync(translation)) { + translations[i18n.locale] = { + locale: i18n, + path: translation + }; } } return translations; @@ -217,34 +226,38 @@ export class i18n { * If no file path is provided, the active file in the editor will be used. * @param filePath The path of the file where the new content file should be created. */ - private static async create(fileUri?: Uri) { + private static async create(fileUri?: Uri | string) { if (!fileUri) { const filePath = ArticleHelper.getActiveFile(); fileUri = filePath ? Uri.file(filePath) : undefined; } if (!fileUri) { - Notifications.warning('No file selected'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoFileSelected)); return; } + if (typeof fileUri === 'string') { + fileUri = Uri.file(fileUri); + } + const i18nSettings = await i18n.getSettings(fileUri.fsPath); if (!i18nSettings) { - Notifications.warning('No i18n configuration found'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); return; } const isDefaultLanguage = await i18n.isDefaultLanguage(fileUri.fsPath); if (!isDefaultLanguage) { - Notifications.warning('The current file cannot be used for i18n content creation'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNotDefaultLocale)); return; } const locale = await window.showQuickPick( i18nSettings.filter((i18n) => i18n.path).map((i18n) => i18n.title || i18n.locale), { - title: 'Create content for locale', - placeHolder: 'To which locale do you want to create a new content?', + title: l10n.t(LocalizationKey.commandsI18nCreateQuickPickTitle), + placeHolder: l10n.t(LocalizationKey.commandsI18nCreateQuickPickPlaceHolder), ignoreFocusOut: true } ); @@ -257,19 +270,19 @@ export class i18n { (i18n) => i18n.title === locale || i18n.locale === locale ); if (!selectedI18n || !selectedI18n.path) { - Notifications.warning('No i18n configuration found'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); return; } let article = await ArticleHelper.getFrontMatterByPath(fileUri.fsPath); if (!article) { - Notifications.warning('No content found'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoFile)); return; } const contentType = ArticleHelper.getContentType(article); if (!contentType) { - Notifications.warning('No content type found'); + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoContentType)); return; } @@ -291,7 +304,7 @@ export class i18n { const newFilePath = join(i18nDir, fileInfo.base); if (await existsAsync(newFilePath)) { - Notifications.warning('File already exists'); + Notifications.error(l10n.t(LocalizationKey.commandsI18nCreateErrorFileExists)); return; } @@ -303,7 +316,14 @@ export class i18n { await openFileInEditor(newFilePath); - Notifications.info(`Created "${selectedI18n.title || selectedI18n.locale}" i18n content file`); + PagesListener.refresh(); + + Notifications.info( + l10n.t( + LocalizationKey.commandsI18nCreateSuccessCreated, + selectedI18n.title || selectedI18n.locale + ) + ); } /** diff --git a/src/components/shadcn/Dropdown.tsx b/src/components/shadcn/Dropdown.tsx index 20cc43d7..9f6132c2 100644 --- a/src/components/shadcn/Dropdown.tsx +++ b/src/components/shadcn/Dropdown.tsx @@ -16,8 +16,6 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup - const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -27,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef< , - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName - const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -186,8 +138,6 @@ export { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, @@ -196,5 +146,4 @@ export { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuRadioGroup, } \ No newline at end of file diff --git a/src/constants/GeneralCommands.ts b/src/constants/GeneralCommands.ts index e761c335..89a060cd 100644 --- a/src/constants/GeneralCommands.ts +++ b/src/constants/GeneralCommands.ts @@ -17,6 +17,7 @@ export const GeneralCommands = { getBranch: 'getBranch', selectBranch: 'gitSelectBranch' }, + runCommand: 'runCommand', getLocalization: 'getLocalization', openOnWebsite: 'openOnWebsite' } diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 5b2d1b2c..84a3c49e 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -1,7 +1,7 @@ import { Messenger, messageHandler } from '@estruyf/vscode/dist/client'; -import { EyeIcon, GlobeEuropeAfricaIcon, CommandLineIcon, TrashIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline'; +import { EyeIcon, GlobeEuropeAfricaIcon, CommandLineIcon, TrashIcon, EllipsisVerticalIcon, LanguageIcon } from '@heroicons/react/24/outline'; import * as React from 'react'; -import { CustomScript, ScriptType } from '../../../models'; +import { CustomScript, I18nConfig, ScriptType } from '../../../models'; import { DashboardMessage } from '../../DashboardMessage'; import { QuickAction } from '../Menu'; import { Alert } from '../Modals/Alert'; @@ -9,10 +9,10 @@ import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../../localization'; import { useRecoilState, useRecoilValue } from 'recoil'; import { SettingsSelector } from '../../state'; -import { GeneralCommands } from '../../../constants'; +import { COMMAND_NAME, GeneralCommands } from '../../../constants'; import { PinIcon } from '../Icons/PinIcon'; import { PinnedItemsAtom } from '../../state/atom/PinnedItems'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../components/shadcn/Dropdown'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../../../components/shadcn/Dropdown'; export interface IContentActionsProps { title: string; @@ -20,6 +20,14 @@ export interface IContentActionsProps { relPath: string; scripts: CustomScript[] | undefined; listView?: boolean; + locale?: I18nConfig; + isDefaultLocale?: boolean; + translations?: { + [locale: string]: { + locale: I18nConfig; + path: string; + }; + }; onOpen: () => void; } @@ -29,7 +37,10 @@ export const ContentActions: React.FunctionComponent = ({ relPath, scripts, onOpen, - listView + listView, + isDefaultLocale, + translations, + locale }: React.PropsWithChildren) => { const [pinnedItems, setPinnedItems] = useRecoilState(PinnedItemsAtom); const [showDeletionAlert, setShowDeletionAlert] = React.useState(false); @@ -52,6 +63,10 @@ export const ContentActions: React.FunctionComponent = ({ setShowDeletionAlert(false); }; + const onOpenFile = (filePath: string) => { + messageHandler.send(DashboardMessage.openFile, filePath); + } + const openOnWebsite = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (settings?.websiteUrl && path) { @@ -84,6 +99,13 @@ export const ContentActions: React.FunctionComponent = ({ [path] ); + const runCommand = React.useCallback((commandId: string) => { + messageHandler.send(GeneralCommands.toVSCode.runCommand, { + command: commandId, + args: path + }) + }, [path]); + const isPinned = React.useMemo(() => { return pinnedItems.includes(relPath); }, [pinnedItems, relPath]); @@ -104,6 +126,45 @@ export const ContentActions: React.FunctionComponent = ({ )); }, [scripts]); + const translationsMenu = React.useMemo(() => { + if (!locale || !translations || Object.keys(translations).length === 0) { + return null; + } + + const crntLocale = translations[locale.locale]; + const otherLocales = Object.entries(translations).filter(([key]) => key !== locale.locale); + + return ( + + + + {l10n.t(LocalizationKey.dashboardContentsContentActionsTranslationsMenu)} + + + + + onOpenFile(crntLocale.path)}> + {crntLocale.locale.title || crntLocale.locale.locale} + + + + + { + otherLocales.map(([key, value]) => ( + onOpenFile(value.path)} + > + {value.locale.title || value.locale.locale} + + )) + } + + + + ); + }, [translations, locale, isDefaultLocale]); + return ( <>
= ({ ) } - +
@@ -161,6 +225,17 @@ export const ContentActions: React.FunctionComponent = ({ ) } + { + locale && isDefaultLocale && ( + runCommand(COMMAND_NAME.i18n.create)}> + + {l10n.t(LocalizationKey.dashboardContentsContentActionsTranslationsCreate)} + + ) + } + + {translationsMenu} + {customScriptActions} diff --git a/src/dashboardWebView/components/Contents/I18nLabel.tsx b/src/dashboardWebView/components/Contents/I18nLabel.tsx index 255315a9..0e63814a 100644 --- a/src/dashboardWebView/components/Contents/I18nLabel.tsx +++ b/src/dashboardWebView/components/Contents/I18nLabel.tsx @@ -13,59 +13,14 @@ export interface II18nLabelProps { export const I18nLabel: React.FunctionComponent = ({ page }: React.PropsWithChildren) => { - - const openFile = (filePath: string) => { - messageHandler.send(DashboardMessage.openFile, filePath); - } - - const dropdown = React.useMemo(() => { - console.log(page) - if (!page.fmLocale || !page.fmTranslations || Object.keys(page.fmTranslations).length < 1) { - return null; - } - - return ( - - - - - - openFile(value)} /> - - - - { - Object.entries(page.fmTranslations).map(([key, value]) => { - return ( - openFile(value)} /> - ); - }) - } - - - ) - }, [page]) - if (!page.fmLocale) { return null; } return (
- {/* - {page.fmLocale.title || page.fmLocale.locale} */} - - {dropdown} + + {page.fmLocale.title || page.fmLocale.locale}
); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 8cbc34f6..ba1edd16 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -79,7 +79,7 @@ export const Item: React.FunctionComponent = ({ statusHtml ? (
) : ( - cardFields?.state && draftField && draftField.name && pageData[draftField.name] ? : null + cardFields?.state && draftField && draftField.name && typeof pageData[draftField.name] !== "undefined" ? : null ) ) }, [statusHtml, cardFields?.state, draftField, pageData]); @@ -111,8 +111,7 @@ export const Item: React.FunctionComponent = ({
diff --git a/src/dashboardWebView/components/Menu/MenuItem.tsx b/src/dashboardWebView/components/Menu/MenuItem.tsx index a6020ab9..948c9e85 100644 --- a/src/dashboardWebView/components/Menu/MenuItem.tsx +++ b/src/dashboardWebView/components/Menu/MenuItem.tsx @@ -5,6 +5,7 @@ export interface IMenuItemProps { title: JSX.Element | string; value?: any; isCurrent?: boolean; + className?: string; disabled?: boolean; onClick: (value: any, e: React.MouseEvent) => void; } @@ -13,12 +14,13 @@ export const MenuItem: React.FunctionComponent = ({ title, value, isCurrent, + className, disabled, onClick }: React.PropsWithChildren) => { return ( onClick(value, e)} > diff --git a/src/dashboardWebView/components/Menu/QuickAction.tsx b/src/dashboardWebView/components/Menu/QuickAction.tsx index fecb7be9..60ff2076 100644 --- a/src/dashboardWebView/components/Menu/QuickAction.tsx +++ b/src/dashboardWebView/components/Menu/QuickAction.tsx @@ -1,12 +1,15 @@ import * as React from 'react'; +import { cn } from '../../../utils/cn'; export interface IQuickActionProps { title: string; + className?: string; onClick: (e: React.MouseEvent) => void; } export const QuickAction: React.FunctionComponent = ({ title, + className, onClick, children }: React.PropsWithChildren) => { @@ -15,7 +18,7 @@ export const QuickAction: React.FunctionComponent = ({ type="button" title={title} onClick={onClick} - className={`px-2 group inline-flex justify-center text-sm font-medium text-[var(--vscode-foreground)] hover:text-[var(--frontmatter-button-hoverBackground)]`} + className={cn(`px-2 group inline-flex justify-center text-sm font-medium text-[var(--vscode-foreground)] hover:text-[var(--frontmatter-button-hoverBackground)]`, className)} > {children} {title} diff --git a/src/dashboardWebView/hooks/usePages.tsx b/src/dashboardWebView/hooks/usePages.tsx index b255baf5..d6ef66b8 100644 --- a/src/dashboardWebView/hooks/usePages.tsx +++ b/src/dashboardWebView/hooks/usePages.tsx @@ -8,6 +8,8 @@ import { FilterValuesAtom, FiltersAtom, FolderSelector, + LocaleAtom, + LocalesAtom, SearchSelector, SettingsSelector, SortingAtom, @@ -22,20 +24,25 @@ import { parseWinPath } from '../../helpers/parseWinPath'; import { sortPages } from '../../utils/sortPages'; import { ExtensionState } from '../../constants'; import { SortingOption } from '../models'; +import { I18nConfig } from '../../models'; +import { usePrevious } from '../../panelWebView/hooks/usePrevious'; export default function usePages(pages: Page[]) { - const [pageItems, setPageItems] = useRecoilState(AllPagesAtom); const [sortedPages, setSortedPages] = useState([]); + const [pageItems, setPageItems] = useRecoilState(AllPagesAtom); const [sorting, setSorting] = useRecoilState(SortingAtom); const [tabInfo, setTabInfo] = useRecoilState(TabInfoAtom); + const [locales, setLocales] = useRecoilState(LocalesAtom); const [, setFilterValues] = useRecoilState(FilterValuesAtom); const settings = useRecoilValue(SettingsSelector); const tab = useRecoilValue(TabSelector); const folder = useRecoilValue(FolderSelector); const search = useRecoilValue(SearchSelector); const tag = useRecoilValue(TagSelector); + const locale = useRecoilValue(LocaleAtom); const category = useRecoilValue(CategorySelector); const filters = useRecoilValue(FiltersAtom); + const tabPrevious = usePrevious(tab); /** * Process all the pages by applying the sorting, filtering and searching. @@ -90,6 +97,11 @@ export default function usePages(pages: Page[]) { ); } + // If filtered by locale + if (locale) { + pagesSorted = pagesSorted.filter((page) => page.fmLocale && page.fmLocale.locale === locale); + } + const filterNames = Object.keys(filters); if (filterNames.length > 0) { for (const filter of filterNames) { @@ -102,7 +114,7 @@ export default function usePages(pages: Page[]) { setSortedPages(pagesSorted); }, - [settings, tab, folder, search, tag, category, sorting, tabInfo, filters] + [settings, tab, folder, search, tag, category, locale, sorting, tabInfo, filters] ); /** @@ -114,8 +126,23 @@ export default function usePages(pages: Page[]) { let crntPages: Page[] = Object.assign([], pages); - // Filter out translations - crntPages = crntPages.filter((page) => !page.fmLocale || (page.fmLocale && page.fmDefaultLocale)) + // Update the translations of pages + crntPages = crntPages.map((page) => { + if (page.fmTranslations) { + const translations = Object.assign({}, page.fmTranslations); + + for (const [key, value] of Object.entries(translations)) { + const translatedPage = crntPages.find((p) => parseWinPath(p.fmFilePath).toLowerCase() === parseWinPath(value.path).toLowerCase()); + if (!translatedPage) { + delete translations[key]; + } + } + + return { ...page, fmTranslations: translations }; + } + + return page; + }); // Process the tab data const draftTypes = Object.assign({}, tabInfo); @@ -193,10 +220,21 @@ export default function usePages(pages: Page[]) { } } + if (tabPrevious !== tab || !locales || locales.length === 0) { + // Store the locale information + const config: I18nConfig[] = []; + crntPages.forEach((page) => { + if (page.fmLocale && !config.some(locale => locale.locale === page.fmLocale?.locale)) { + config.push(page.fmLocale); + } + }); + setLocales(config); + } + // Set the pages setPageItems(crntPages); }, - [tab, tabInfo, settings, filters] + [tab, tabInfo, settings, filters, locales, tabPrevious] ); /** @@ -238,7 +276,7 @@ export default function usePages(pages: Page[]) { } else { startPageProcessing(); } - }, [settings?.draftField, pages, sorting, search, tag, category, filters, folder]); + }, [settings?.draftField, pages, sorting, search, tag, category, locale, filters, folder]); useEffect(() => { processByTab(sortedPages); diff --git a/src/dashboardWebView/state/atom/LocaleAtom.ts b/src/dashboardWebView/state/atom/LocaleAtom.ts new file mode 100644 index 00000000..fc52f909 --- /dev/null +++ b/src/dashboardWebView/state/atom/LocaleAtom.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +export const DEFAULT_LOCALE_STATE = ''; + +export const LocaleAtom = atom({ + key: 'LocaleAtom', + default: DEFAULT_LOCALE_STATE +}); diff --git a/src/dashboardWebView/state/atom/LocalesAtom.ts b/src/dashboardWebView/state/atom/LocalesAtom.ts new file mode 100644 index 00000000..911f45f6 --- /dev/null +++ b/src/dashboardWebView/state/atom/LocalesAtom.ts @@ -0,0 +1,7 @@ +import { atom } from 'recoil'; +import { I18nConfig } from '../../../models'; + +export const LocalesAtom = atom({ + key: 'LocalesAtom', + default: undefined +}); diff --git a/src/dashboardWebView/state/atom/index.ts b/src/dashboardWebView/state/atom/index.ts index caad6272..de507e13 100644 --- a/src/dashboardWebView/state/atom/index.ts +++ b/src/dashboardWebView/state/atom/index.ts @@ -9,6 +9,8 @@ export * from './FolderAtom'; export * from './GroupingAtom'; export * from './LightboxAtom'; export * from './LoadingAtom'; +export * from './LocaleAtom'; +export * from './LocalesAtom'; export * from './MediaFoldersAtom'; export * from './MediaTotalAtom'; export * from './ModeAtom'; diff --git a/src/listeners/general/BaseListener.ts b/src/listeners/general/BaseListener.ts index a2564fd5..3340dac6 100644 --- a/src/listeners/general/BaseListener.ts +++ b/src/listeners/general/BaseListener.ts @@ -3,7 +3,7 @@ import { Dashboard } from '../../commands/Dashboard'; import { PanelProvider } from '../../panelWebView/PanelProvider'; import { ArticleHelper, Extension } from '../../helpers'; import { Logger } from '../../helpers/Logger'; -import { commands, Uri, window } from 'vscode'; +import { commands, Uri, window, workspace } from 'vscode'; import { PostMessageData } from '../../models'; import { Preview } from '../../commands'; import { urlJoin } from 'url-join-ts'; @@ -19,6 +19,12 @@ export abstract class BaseListener { case GeneralCommands.toVSCode.openOnWebsite: this.openOnWebsite(msg.payload); break; + case GeneralCommands.toVSCode.runCommand: + if (msg.payload) { + const { command, args } = msg.payload; + commands.executeCommand(command, args); + } + break; } } diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 446b80c0..d123bf3a 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -315,6 +315,14 @@ export enum LocalizationKey { * Are you sure you want to delete the "{0}" content? */ dashboardContentsContentActionsAlertDescription = 'dashboard.contents.contentActions.alert.description', + /** + * Create translation + */ + dashboardContentsContentActionsTranslationsCreate = 'dashboard.contents.contentActions.translations.create', + /** + * Translations + */ + dashboardContentsContentActionsTranslationsMenu = 'dashboard.contents.contentActions.translations.menu', /** * */ @@ -427,6 +435,14 @@ export enum LocalizationKey { * Please close the dashboard and try again. */ dashboardErrorViewDescription = 'dashboard.errorView.description', + /** + * Locale + */ + dashboardFiltersLanguageFilterLabel = 'dashboard.filters.languageFilter.label', + /** + * All + */ + dashboardFiltersLanguageFilterAll = 'dashboard.filters.languageFilter.all', /** * Home */ @@ -1672,6 +1688,42 @@ export enum LocalizationKey { * Create folder */ commandsFoldersGetNotificationErrorCreateAction = 'commands.folders.get.notificationError.create.action', + /** + * No file selected. + */ + commandsI18nCreateWarningNoFileSelected = 'commands.i18n.create.warning.noFileSelected', + /** + * The file could not be retrieved. + */ + commandsI18nCreateWarningNoFile = 'commands.i18n.create.warning.noFile', + /** + * Content type could not be retrieved for the current file. + */ + commandsI18nCreateWarningNoContentType = 'commands.i18n.create.warning.noContentType', + /** + * No i18n configuration found. + */ + commandsI18nCreateWarningNoConfig = 'commands.i18n.create.warning.noConfig', + /** + * The current file cannot be used for i18n content creation. + */ + commandsI18nCreateWarningNotDefaultLocale = 'commands.i18n.create.warning.notDefaultLocale', + /** + * The i18n translation already exists. + */ + commandsI18nCreateErrorFileExists = 'commands.i18n.create.error.fileExists', + /** + * Created "{0}" i18n content file. + */ + commandsI18nCreateSuccessCreated = 'commands.i18n.create.success.created', + /** + * Create content for locale + */ + commandsI18nCreateQuickPickTitle = 'commands.i18n.create.quickPick.title', + /** + * To which locale do you want to create a new content? + */ + commandsI18nCreateQuickPickPlaceHolder = 'commands.i18n.create.quickPick.placeHolder', /** * Preview: {0} */ From 4a53a180a7376c5cf21166527659a54b12355c03 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 19 Feb 2024 18:17:08 +0100 Subject: [PATCH 5/8] #756 - Page bundle support added --- src/commands/i18n.ts | 161 +++++++++++------- .../components/Contents/ContentActions.tsx | 6 +- src/helpers/ArticleHelper.ts | 33 +++- src/helpers/ContentType.ts | 1 - 4 files changed, 133 insertions(+), 68 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index ebd488bd..7de89362 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -21,16 +21,7 @@ import { LocalizationKey } from '../localization'; // TODO: // Allow sponsors to automatically translate the content -// Support page bundles -// Filter on locale ✅ -// Locale settings on the page folder level and global level -// Show the i18n content -> if default locale is in subfolder, the other content is not found ✅ -// Update the page folder setting to include the locales property (use #ref) ✅ -// Update the default card item when the translation is removed ✅ -// Add action to create new translation ✅ -// Trigger page update when translation is created ✅ -// Add translations to the menu ✅ -// Localization of the React components ✅ +// Support for DeepL, Azure export class i18n { /** @@ -54,21 +45,7 @@ export class i18n { const i18nSettings = Settings.get(SETTING_CONTENT_I18N); let pageFolder = Folders.getPageFolderByFilePath(filePath); if (!pageFolder) { - const folders = Folders.get(); - - const localeFolders = folders?.filter((folder) => folder.defaultLocale); - if (!localeFolders) { - return; - } - - const fileName = parse(filePath).base; - for (const folder of localeFolders) { - const defaultFile = join(folder.path, fileName); - if (await existsAsync(defaultFile)) { - pageFolder = folder; - break; - } - } + pageFolder = await i18n.getPageFolder(filePath); } if (!pageFolder || !pageFolder.locales) { @@ -94,11 +71,17 @@ export class i18n { return false; } - const fileInfo = parse(filePath); - const dir = fileInfo.dir; + const fileInfo = await i18n.getFileInfo(filePath); if (pageFolder.path) { - return parseWinPath(dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase(); + let pageFolderPath = parseWinPath(pageFolder.path); + if (!pageFolderPath.endsWith('/')) { + pageFolderPath += '/'; + } + + return ( + parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).toLowerCase() + ); } return false; @@ -115,31 +98,34 @@ export class i18n { return; } - const pageFolder = Folders.getPageFolderByFilePath(filePath); - const fileInfo = parse(filePath); + let pageFolder = Folders.getPageFolderByFilePath(filePath); + + const fileInfo = await i18n.getFileInfo(filePath); + if (pageFolder && pageFolder.defaultLocale) { + let pageFolderPath = parseWinPath(pageFolder.path); + if (!pageFolderPath.endsWith('/')) { + pageFolderPath += '/'; + } + if ( pageFolder.path && - parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase() + parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).toLowerCase() ) { - return i18nSettings.find((i18n) => i18n.locale === pageFolder.defaultLocale); + return i18nSettings.find((i18n) => i18n.locale === pageFolder?.defaultLocale); } } - const folders = Folders.get(); - if (!folders) { + pageFolder = await i18n.getPageFolder(filePath); + if (!pageFolder) { return; } - const fileName = fileInfo.base; - const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); - for (const folder of defaultLanguageFolders) { - for (const locale of i18nSettings) { - if (locale.path && folder.defaultLocale !== locale.locale) { - const translation = join(folder.path, locale.path, fileName); - if (parseWinPath(translation).toLowerCase() === parseWinPath(filePath).toLowerCase()) { - return locale; - } + for (const locale of i18nSettings) { + if (locale.path && pageFolder.defaultLocale !== locale.locale) { + const translation = join(pageFolder.path, locale.path, fileInfo.filename); + if (parseWinPath(translation).toLowerCase() === parseWinPath(filePath).toLowerCase()) { + return locale; } } } @@ -173,11 +159,12 @@ export class i18n { }; } = {}; - const pageFolder = Folders.getPageFolderByFilePath(filePath); - const fileName = parse(filePath).base; + let pageFolder = Folders.getPageFolderByFilePath(filePath); + const fileInfo = await i18n.getFileInfo(filePath); + if (pageFolder && pageFolder.defaultLocale) { for (const i18n of i18nSettings) { - const translation = join(pageFolder.path, i18n.path || '', fileName); + const translation = join(pageFolder.path, i18n.path || '', fileInfo.filename); if (await existsAsync(translation)) { translations[i18n.locale] = { locale: i18n, @@ -188,27 +175,13 @@ export class i18n { return translations; } - const folders = Folders.get(); - if (!folders) { - return; - } - - const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); - let defaultLanguageFolder: ContentFolder | undefined; - for (const folder of defaultLanguageFolders) { - const defaultFile = join(folder.path, fileName); - if (await existsAsync(defaultFile)) { - defaultLanguageFolder = folder; - break; - } - } - - if (!defaultLanguageFolder) { + pageFolder = await i18n.getPageFolder(filePath); + if (!pageFolder) { return translations; } for (const i18n of i18nSettings) { - const translation = join(defaultLanguageFolder.path, i18n.path || '', fileName); + const translation = join(pageFolder.path, i18n.path || '', fileInfo.filename); if (await existsAsync(translation)) { translations[i18n.locale] = { locale: i18n, @@ -288,7 +261,16 @@ export class i18n { // Get the directory of the file const fileInfo = parse(fileUri.fsPath); - const i18nDir = join(fileInfo.dir, selectedI18n.path); + let dir = fileInfo.dir; + let pageBundleDir = ''; + + if (await ArticleHelper.isPageBundle(fileUri.fsPath)) { + dir = ArticleHelper.getPageFolderFromBundlePath(fileUri.fsPath); + pageBundleDir = fileUri.fsPath.replace(dir, ''); + pageBundleDir = join(parse(pageBundleDir).dir); + } + + const i18nDir = join(dir, selectedI18n.path, pageBundleDir); if (!(await existsAsync(i18nDir))) { await workspace.fs.createDirectory(Uri.file(i18nDir)); @@ -326,6 +308,57 @@ export class i18n { ); } + /** + * Retrieves the filename and directory information from the given file path. + * If the file is a page bundle, the directory will be adjusted accordingly. + * @param filePath - The path of the file. + * @returns An object containing the filename and directory. + */ + private static async getFileInfo(filePath: string): Promise<{ filename: string; dir: string }> { + const fileInfo = parse(filePath); + let filename = fileInfo.base; + let dir = fileInfo.dir; + + const isPageBundle = await ArticleHelper.isPageBundle(filePath); + if (isPageBundle) { + dir = ArticleHelper.getPageFolderFromBundlePath(filePath); + filename = join(parseWinPath(filePath).replace(parseWinPath(dir), '')); + } + + if (!dir.endsWith('/')) { + dir += '/'; + } + + return { + filename, + dir + }; + } + + /** + * Retrieves the page folder for a given file path. + * + * @param filePath - The path of the file. + * @returns A promise that resolves to the ContentFolder object representing the page folder, or undefined if not found. + */ + private static async getPageFolder(filePath: string): Promise { + const folders = Folders.get(); + + const localeFolders = folders?.filter((folder) => folder.defaultLocale); + if (!localeFolders) { + return; + } + + const fileInfo = await i18n.getFileInfo(filePath); + + for (const folder of localeFolders) { + const defaultFile = join(folder.path, fileInfo.filename); + if (await existsAsync(defaultFile)) { + return folder; + } + } + } + /** * Updates the front matter of an article with internationalization (i18n) support. * diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 84a3c49e..b4ae10e9 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -134,6 +134,10 @@ export const ContentActions: React.FunctionComponent = ({ const crntLocale = translations[locale.locale]; const otherLocales = Object.entries(translations).filter(([key]) => key !== locale.locale); + if (otherLocales.length === 0) { + return null; + } + return ( @@ -200,7 +204,7 @@ export const ContentActions: React.FunctionComponent = ({ )} - + {l10n.t(LocalizationKey.dashboardContentsContentActionsActionMenuButtonTitle)} diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index d3aa808f..e1473736 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -37,7 +37,7 @@ import { import { format, parse } from 'date-fns'; import { Notifications } from './Notifications'; import { Article } from '../commands'; -import { join } from 'path'; +import { join, parse as parseFile } from 'path'; import { EditorHelper } from '@estruyf/vscode'; import sanitize from '../helpers/Sanitize'; import { ContentType as IContentType } from '../models'; @@ -49,7 +49,7 @@ import { Link, Parent } from 'mdast-util-from-markdown/lib'; import { Content } from 'mdast'; import { CustomScript } from './CustomScript'; import { Folders } from '../commands/Folders'; -import { existsAsync, readFileAsync } from '../utils'; +import { existsAsync } from '../utils'; import { mkdirAsync } from '../utils/mkdirAsync'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; @@ -302,6 +302,35 @@ export class ArticleHelper { return isSupportedLanguage; } + /** + * Checks if the given file path represents a page bundle. + * + * @param filePath - The path of the file to check. + * @returns A boolean indicating whether the file is a page bundle or not. + */ + public static async isPageBundle(filePath: string) { + let article = await ArticleHelper.getFrontMatterByPath(filePath); + if (!article) { + return false; + } + + const contentType = ArticleHelper.getContentType(article); + return !!contentType.pageBundle; + } + + /** + * Retrieves the page folder from the given bundle file path. + * + * @param filePath - The file path of the bundle. + * @returns The page folder path. + */ + public static getPageFolderFromBundlePath(filePath: string) { + // Remove the last folder from the dir + const dir = parseFile(filePath).dir; + const lastSlash = dir.lastIndexOf('/'); + return dir.substring(0, lastSlash); + } + /** * Get date from front matter */ diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts index a335945c..64980049 100644 --- a/src/helpers/ContentType.ts +++ b/src/helpers/ContentType.ts @@ -1039,7 +1039,6 @@ export class ContentType { } } else { const defaultValue = field.default; - console.log(field.name, defaultValue, Array.isArray(defaultValue)); if (typeof defaultValue === 'string') { data[field.name] = await ContentType.processFieldPlaceholders( From cc375801c2cc9c7462fcd5b1991599979bf7a0c3 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 19 Feb 2024 20:33:19 +0100 Subject: [PATCH 6/8] #756 - Faster translation checks --- src/commands/i18n.ts | 22 ++++++++++++++++++++-- src/services/PagesParser.ts | 1 + 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 7de89362..ad6a106b 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -24,6 +24,8 @@ import { LocalizationKey } from '../localization'; // Support for DeepL, Azure export class i18n { + private static processedFiles: { [filePath: string]: { dir: string; filename: string; isPageBundle: boolean; }} = {}; + /** * Registers the i18n commands. */ @@ -31,6 +33,15 @@ export class i18n { const subscriptions = Extension.getInstance().subscriptions; subscriptions.push(commands.registerCommand(COMMAND_NAME.i18n.create, i18n.create)); + + i18n.clearFiles(); + } + + /** + * Clear the processed files + */ + public static clearFiles() { + i18n.processedFiles = {}; } /** @@ -315,6 +326,10 @@ export class i18n { * @returns An object containing the filename and directory. */ private static async getFileInfo(filePath: string): Promise<{ filename: string; dir: string }> { + if (i18n.processedFiles[filePath]) { + return i18n.processedFiles[filePath]; + } + const fileInfo = parse(filePath); let filename = fileInfo.base; let dir = fileInfo.dir; @@ -329,10 +344,13 @@ export class i18n { dir += '/'; } - return { + i18n.processedFiles[filePath] = { + isPageBundle, filename, dir - }; + } + + return i18n.processedFiles[filePath]; } /** diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index f573ebff..d3195d2d 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -77,6 +77,7 @@ export class PagesParser { * Parse all pages in the workspace */ public static async parsePages() { + i18n.clearFiles(); const ext = Extension.getInstance(); // Update the dashboard with the fresh data From 49e7fe6377dc009c9f6a7ca423c2022a614b3a61 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Tue, 20 Feb 2024 21:59:36 +0100 Subject: [PATCH 7/8] #756 - Support the ability to use Deepl --- l10n/bundle.l10n.json | 1 + package.json | 334 +++++++++++++++----------- package.nls.json | 1 + src/commands/i18n.ts | 95 +++++++- src/components/shadcn/Dropdown.tsx | 2 +- src/constants/settings.ts | 2 + src/localization/localization.enum.ts | 4 + 7 files changed, 283 insertions(+), 156 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index e20ccd28..44dfcdf6 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -537,6 +537,7 @@ "commands.i18n.create.success.created": "Created \"{0}\" i18n content file.", "commands.i18n.create.quickPick.title": "Create content for locale", "commands.i18n.create.quickPick.placeHolder": "To which locale do you want to create a new content?", + "commands.i18n.translate.progress.title": "Translating content...", "commands.preview.panel.title": "Preview: {0}", "commands.preview.askUserToPickFolder.title": "Select the folder of the article to preview", diff --git a/package.json b/package.json index 2e628580..96bf4809 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "color": "#0e131f", "theme": "dark" }, - "badges": [{ + "badges": [ + { "description": "version", "url": "https://img.shields.io/github/package-json/v/estruyf/vscode-front-matter?color=green&label=vscode-front-matter&style=flat-square", "href": "https://github.com/estruyf/vscode-front-matter" @@ -70,7 +71,8 @@ "**/.frontmatter/config/*.json": "jsonc" } }, - "keybindings": [{ + "keybindings": [ + { "command": "frontMatter.dashboard", "key": "alt+d" }, @@ -88,19 +90,23 @@ } ], "viewsContainers": { - "activitybar": [{ - "id": "frontmatter-explorer", - "title": "FM", - "icon": "$(fm-logo)" - }] + "activitybar": [ + { + "id": "frontmatter-explorer", + "title": "FM", + "icon": "$(fm-logo)" + } + ] }, "views": { - "frontmatter-explorer": [{ - "id": "frontMatter.explorer", - "name": "Front Matter", - "icon": "$(fm-logo)", - "type": "webview" - }] + "frontmatter-explorer": [ + { + "id": "frontMatter.explorer", + "name": "Front Matter", + "icon": "$(fm-logo)", + "type": "webview" + } + ] }, "configuration": { "title": "%settings.configuration.title%", @@ -168,7 +174,8 @@ "frontMatter.content.defaultFileType": { "type": "string", "default": "md", - "oneOf": [{ + "oneOf": [ + { "enum": [ "md", "mdx" @@ -184,7 +191,8 @@ "frontMatter.content.defaultSorting": { "type": "string", "default": "", - "oneOf": [{ + "oneOf": [ + { "enum": [ "LastModifiedAsc", "LastModifiedDesc", @@ -531,7 +539,8 @@ "categories" ], "markdownDescription": "%setting.frontMatter.content.filters.markdownDescription%", - "items": [{ + "items": [ + { "type": "string" }, { @@ -599,7 +608,8 @@ "command": { "$id": "#scriptCommand", "type": "string", - "anyOf": [{ + "anyOf": [ + { "enum": [ "node", "bash", @@ -806,7 +816,8 @@ "title", "file" ], - "anyOf": [{ + "anyOf": [ + { "required": [ "schema" ] @@ -860,7 +871,8 @@ "id", "path" ], - "anyOf": [{ + "anyOf": [ + { "required": [ "schema" ] @@ -1101,26 +1113,29 @@ } } }, - "default": [{ - "name": "default", - "fileTypes": null, - "fields": [{ - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Caption", - "name": "caption", - "type": "string" - }, - { - "title": "Alt text", - "name": "alt", - "type": "string" - } - ] - }], + "default": [ + { + "name": "default", + "fileTypes": null, + "fields": [ + { + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Caption", + "name": "caption", + "type": "string" + }, + { + "title": "Alt text", + "name": "alt", + "type": "string" + } + ] + } + ], "scope": "Media" }, "frontMatter.media.supportedMimeTypes": { @@ -1350,7 +1365,8 @@ "default": "", "description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description%", "not": { - "anyOf": [{ + "anyOf": [ + { "const": "" }, { @@ -1544,7 +1560,8 @@ "type", "name" ], - "allOf": [{ + "allOf": [ + { "if": { "properties": { "type": { @@ -1752,48 +1769,51 @@ "fields" ] }, - "default": [{ - "name": "default", - "pageBundle": false, - "fields": [{ - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Description", - "name": "description", - "type": "string" - }, - { - "title": "Publishing date", - "name": "date", - "type": "datetime", - "default": "{{now}}", - "isPublishDate": true - }, - { - "title": "Content preview", - "name": "preview", - "type": "image" - }, - { - "title": "Is in draft", - "name": "draft", - "type": "boolean" - }, - { - "title": "Tags", - "name": "tags", - "type": "tags" - }, - { - "title": "Categories", - "name": "categories", - "type": "categories" - } - ] - }], + "default": [ + { + "name": "default", + "pageBundle": false, + "fields": [ + { + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Description", + "name": "description", + "type": "string" + }, + { + "title": "Publishing date", + "name": "date", + "type": "datetime", + "default": "{{now}}", + "isPublishDate": true + }, + { + "title": "Content preview", + "name": "preview", + "type": "image" + }, + { + "title": "Is in draft", + "name": "draft", + "type": "boolean" + }, + { + "title": "Tags", + "name": "tags", + "type": "tags" + }, + { + "title": "Categories", + "name": "categories", + "type": "categories" + } + ] + } + ], "scope": "Taxonomy" }, "frontMatter.taxonomy.customTaxonomy": { @@ -1806,7 +1826,8 @@ "type": "string", "description": "%setting.frontMatter.taxonomy.customTaxonomy.items.properties.id.description%", "not": { - "anyOf": [{ + "anyOf": [ + { "const": "" }, { @@ -2000,10 +2021,16 @@ "frontMatter.website.host": { "type": "string", "markdownDescription": "%setting.frontMatter.website.host.markdownDescription%" + }, + "frontMatter.integration.deepl": { + "type": "string", + "default": "", + "markdownDescription": "%setting.frontMatter.integration.deepl.markdownDescription%" } } }, - "commands": [{ + "commands": [ + { "command": "frontMatter.project.switch", "title": "%command.frontMatter.project.switch%", "category": "Front Matter", @@ -2329,16 +2356,21 @@ } } ], - "submenus": [{ - "id": "frontmatter.submenu", - "label": "Front Matter" - }], + "submenus": [ + { + "id": "frontmatter.submenu", + "label": "Front Matter" + } + ], "menus": { - "webview/context": [{ - "command": "workbench.action.webview.openDeveloperTools", - "when": "frontMatter:isDevelopment" - }], - "editor/title": [{ + "webview/context": [ + { + "command": "workbench.action.webview.openDeveloperTools", + "when": "frontMatter:isDevelopment" + } + ], + "editor/title": [ + { "command": "frontMatter.markup.heading", "group": "navigation@-133", "when": "frontMatter:file:isValid == true && frontMatter:markdown:wysiwyg" @@ -2424,11 +2456,14 @@ "when": "resourceFilename == 'frontmatter.json'" } ], - "explorer/context": [{ - "submenu": "frontmatter.submenu", - "group": "frontmatter@1" - }], - "frontmatter.submenu": [{ + "explorer/context": [ + { + "submenu": "frontmatter.submenu", + "group": "frontmatter@1" + } + ], + "frontmatter.submenu": [ + { "command": "frontMatter.createFromTemplate", "when": "explorerResourceIsFolder", "group": "frontmatter@1" @@ -2444,7 +2479,8 @@ "group": "frontmatter@3" } ], - "commandPalette": [{ + "commandPalette": [ + { "command": "frontMatter.init", "when": "frontMatterCanInit" }, @@ -2597,7 +2633,8 @@ "when": "frontMatter:file:isValid == true" } ], - "view/title": [{ + "view/title": [ + { "command": "frontMatter.chatbot", "group": "navigation@0", "when": "view == frontMatter.explorer" @@ -2629,52 +2666,57 @@ } ] }, - "grammars": [{ - "path": "./syntaxes/hugo.tmLanguage.json", - "scopeName": "frontmatter.markdown.hugo", - "injectTo": [ - "text.html.markdown" - ] - }], - "walkthroughs": [{ - "id": "frontmatter.welcome", - "title": "Get started with Front Matter", - "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", - "steps": [{ - "id": "frontmatter.welcome.init", - "title": "Get started", - "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", - "media": { - "markdown": "assets/walkthrough/get-started.md" + "grammars": [ + { + "path": "./syntaxes/hugo.tmLanguage.json", + "scopeName": "frontmatter.markdown.hugo", + "injectTo": [ + "text.html.markdown" + ] + } + ], + "walkthroughs": [ + { + "id": "frontmatter.welcome", + "title": "Get started with Front Matter", + "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", + "steps": [ + { + "id": "frontmatter.welcome.init", + "title": "Get started", + "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", + "media": { + "markdown": "assets/walkthrough/get-started.md" + }, + "completionEvents": [ + "onContext:frontMatterInitialized" + ] }, - "completionEvents": [ - "onContext:frontMatterInitialized" - ] - }, - { - "id": "frontmatter.welcome.documentation", - "title": "Documentation", - "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", - "media": { - "markdown": "assets/walkthrough/documentation.md" + { + "id": "frontmatter.welcome.documentation", + "title": "Documentation", + "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", + "media": { + "markdown": "assets/walkthrough/documentation.md" + }, + "completionEvents": [ + "onLink:https://frontmatter.codes/docs" + ] }, - "completionEvents": [ - "onLink:https://frontmatter.codes/docs" - ] - }, - { - "id": "frontmatter.welcome.supporter", - "title": "Support the project", - "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", - "media": { - "markdown": "assets/walkthrough/support-the-project.md" - }, - "completionEvents": [ - "onLink:https://github.com/sponsors/estruyf" - ] - } - ] - }] + { + "id": "frontmatter.welcome.supporter", + "title": "Support the project", + "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", + "media": { + "markdown": "assets/walkthrough/support-the-project.md" + }, + "completionEvents": [ + "onLink:https://github.com/sponsors/estruyf" + ] + } + ] + } + ] }, "scripts": { "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", @@ -2803,4 +2845,4 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^2.0.6" } -} \ No newline at end of file +} diff --git a/package.nls.json b/package.nls.json index 597529d7..3dc8a4dc 100644 --- a/package.nls.json +++ b/package.nls.json @@ -268,6 +268,7 @@ "setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.customType.description": "Specify the name of the custom field type to use.", "setting.frontMatter.taxonomy.contentTypes.items.properties.clearEmpty.description": "Specify if the empty values should be cleared.", "setting.frontMatter.website.host.markdownDescription": "Specify the host URL of your website. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.website.url)", + "setting.frontMatter.integration.deepl.markdownDescription": "Specify the DeepL API key to use for the translation of your content. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.integration.deepl)", "command.frontMatter.settings.refresh": "Refresh Front Matter Settings", "setting.frontMatter.config.dynamicFilePath.markdownDescription": "Specify the path to the dynamic config file (ex: [[workspace]]/config.js). [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.config.dynamicfilepath)", "setting.frontMatter.taxonomy.contentTypes.items.properties.allowAsSubContent.description": "Specify if the content type can be used as sub content.", diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index ad6a106b..61bd09bf 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -1,15 +1,16 @@ -import { Uri, commands, window, workspace } from 'vscode'; +import { ProgressLocation, Uri, commands, window, workspace } from 'vscode'; import { ArticleHelper, ContentType, Extension, FrameworkDetector, + Logger, Notifications, Settings, openFileInEditor, parseWinPath } from '../helpers'; -import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants'; +import { COMMAND_NAME, SETTING_CONTENT_I18N, SETTING_INTEGRATION_DEEPL } from '../constants'; import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models'; import { join, parse } from 'path'; import { existsAsync } from '../utils'; @@ -19,12 +20,10 @@ import { PagesListener } from '../listeners/dashboard'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; -// TODO: -// Allow sponsors to automatically translate the content -// Support for DeepL, Azure - export class i18n { - private static processedFiles: { [filePath: string]: { dir: string; filename: string; isPageBundle: boolean; }} = {}; + private static processedFiles: { + [filePath: string]: { dir: string; filename: string; isPageBundle: boolean }; + } = {}; /** * Registers the i18n commands. @@ -33,7 +32,7 @@ export class i18n { const subscriptions = Extension.getInstance().subscriptions; subscriptions.push(commands.registerCommand(COMMAND_NAME.i18n.create, i18n.create)); - + i18n.clearFiles(); } @@ -301,6 +300,11 @@ export class i18n { return; } + const sourceLocale = await i18n.getLocale(fileUri.fsPath); + if (sourceLocale?.locale) { + article = await i18n.translate(article, sourceLocale, selectedI18n); + } + const newFileUri = Uri.file(newFilePath); await workspace.fs.writeFile( newFileUri, @@ -319,6 +323,79 @@ export class i18n { ); } + /** + * Translates the given article from the source locale to the target locale using DeepL translation service. + * @param article - The article to be translated. + * @param sourceLocale - The source locale configuration. + * @param targetLocale - The target locale configuration. + * @returns A promise that resolves to the translated article. + */ + private static async translate( + article: ParsedFrontMatter, + sourceLocale: I18nConfig, + targetLocale: I18nConfig + ) { + return new Promise(async (resolve) => { + const authKey = Settings.get(SETTING_INTEGRATION_DEEPL); + if (!authKey) { + resolve(article); + return; + } + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: l10n.t(LocalizationKey.commandsI18nTranslateProgressTitle), + cancellable: false + }, + async () => { + const title = article.data.title; + const description = article.data.description; + const content = article.content; + + try { + const body = JSON.stringify({ + text: [title, description, content], + source_lang: sourceLocale.locale, + target_lang: targetLocale.locale + }); + + let host = authKey.endsWith(':fx') ? 'api-free.deepl.com' : 'api.deepl.com'; + + const response = await fetch(`https://${host}/v2/translate`, { + method: 'POST', + headers: { + Authorization: `DeepL-Auth-Key ${authKey}`, + 'User-Agent': `FrontMatterCMS/${Extension.getInstance().version}`, + 'Content-Type': 'application/json', + 'content-length': body.length.toString(), + Accept: 'application/json' + }, + body + }); + + if (!response.ok) { + throw new Error(`DeepL: ${response.statusText}`); + } + + const data = await response.json(); + if (!data.translations || data.translations.length < 3) { + 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; + } catch (error) { + Notifications.error(`${(error as Error).message}`); + } + + resolve(article); + } + ); + }); + } + /** * Retrieves the filename and directory information from the given file path. * If the file is a page bundle, the directory will be adjusted accordingly. @@ -348,7 +425,7 @@ export class i18n { isPageBundle, filename, dir - } + }; return i18n.processedFiles[filePath]; } diff --git a/src/components/shadcn/Dropdown.tsx b/src/components/shadcn/Dropdown.tsx index 9f6132c2..1161f034 100644 --- a/src/components/shadcn/Dropdown.tsx +++ b/src/components/shadcn/Dropdown.tsx @@ -3,7 +3,7 @@ import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { cn } from "../../utils/cn" -import { CheckCircleIcon, CheckIcon, ChevronRightIcon } from "@heroicons/react/24/outline" +import { ChevronRightIcon } from "@heroicons/react/24/outline" const DropdownMenu = DropdownMenuPrimitive.Root diff --git a/src/constants/settings.ts b/src/constants/settings.ts index d184a65b..adde4dfd 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -114,6 +114,8 @@ export const SETTING_SNIPPETS_WRAPPER = 'snippets.wrapper.enabled'; export const SETTING_WEBSITE_URL = 'website.host'; +export const SETTING_INTEGRATION_DEEPL = 'integration.deepl'; + /** * Sponsors only settings */ diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index d123bf3a..418c9570 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1724,6 +1724,10 @@ export enum LocalizationKey { * To which locale do you want to create a new content? */ commandsI18nCreateQuickPickPlaceHolder = 'commands.i18n.create.quickPick.placeHolder', + /** + * Translating content... + */ + commandsI18nTranslateProgressTitle = 'commands.i18n.translate.progress.title', /** * Preview: {0} */ From e9af7e17938da6fadfd4d6145ebdadfd745234e5 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 21 Feb 2024 10:00:48 +0100 Subject: [PATCH 8/8] #756 - Move deepl key to secret storage --- l10n/bundle.l10n.json | 8 + package.json | 334 ++++++++---------- package.nls.json | 1 - src/commands/i18n.ts | 4 +- src/constants/ExtensionState.ts | 4 + src/constants/GeneralCommands.ts | 4 + src/constants/settings.ts | 2 - .../SettingsView/IntegrationsView.tsx | 58 +++ .../components/SettingsView/SettingsView.tsx | 7 + src/helpers/Extension.ts | 8 + src/listeners/dashboard/SettingsListener.ts | 61 ++++ src/localization/localization.enum.ts | 24 ++ 12 files changed, 322 insertions(+), 193 deletions(-) create mode 100644 src/dashboardWebView/components/SettingsView/IntegrationsView.tsx diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 44dfcdf6..6ddfaeab 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -46,6 +46,8 @@ "settings.view.common": "Common", "settings.view.contentFolders": "Content folders", "settings.view.astro": "Astro", + "settings.view.integration": "Integration", + "settings.openOnStartup": "Open dashboard on startup", "settings.contentTypes": "Content types", "settings.contentFolders": "Content folders", @@ -57,12 +59,17 @@ "settings.git.commitMessage": "Commit message", "settings.git.submoduleInfo": "When working with Git submodules, you can refer to the submodule settings in the documentation.", "settings.git.submoduleLink": "Read more about Git submodules", + "settings.integration.title": "Integration", "settings.commonSettings.website.title": "Website and SSG settings", "settings.commonSettings.previewUrl": "Preview URL", "settings.commonSettings.websiteUrl": "Website URL", "settings.commonSettings.startCommand": "SSG/Framework start command", + "settings.integrationsView.deepl.title": "DeepL", + "settings.integrationsView.deepl.intput.label": "Authentication key", + "settings.integrationsView.deepl.intput.placeholder": "Enter your DeepL authentication key", + "developer.title": "Developer mode", "developer.reload.title": "Reload the dashboard", "developer.reload.label": "Reload", @@ -728,6 +735,7 @@ "listeners.dashboard.settingsListener.triggerTemplate.progress.title": "Downloading and initializing the template...", "listeners.dashboard.settingsListener.triggerTemplate.download.error": "Failed to download the template.", "listeners.dashboard.settingsListener.triggerTemplate.init.error": "Failed to initialize the template.", + "listeners.dashboard.settingsListener.setSecretValue.message": "Setting has been updated.", "listeners.dashboard.snippetListener.addSnippet.missingFields.warning": "Snippet missing title or body", "listeners.dashboard.snippetListener.addSnippet.exists.warning": "Snippet with the same title already exists", diff --git a/package.json b/package.json index 96bf4809..2e628580 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "color": "#0e131f", "theme": "dark" }, - "badges": [ - { + "badges": [{ "description": "version", "url": "https://img.shields.io/github/package-json/v/estruyf/vscode-front-matter?color=green&label=vscode-front-matter&style=flat-square", "href": "https://github.com/estruyf/vscode-front-matter" @@ -71,8 +70,7 @@ "**/.frontmatter/config/*.json": "jsonc" } }, - "keybindings": [ - { + "keybindings": [{ "command": "frontMatter.dashboard", "key": "alt+d" }, @@ -90,23 +88,19 @@ } ], "viewsContainers": { - "activitybar": [ - { - "id": "frontmatter-explorer", - "title": "FM", - "icon": "$(fm-logo)" - } - ] + "activitybar": [{ + "id": "frontmatter-explorer", + "title": "FM", + "icon": "$(fm-logo)" + }] }, "views": { - "frontmatter-explorer": [ - { - "id": "frontMatter.explorer", - "name": "Front Matter", - "icon": "$(fm-logo)", - "type": "webview" - } - ] + "frontmatter-explorer": [{ + "id": "frontMatter.explorer", + "name": "Front Matter", + "icon": "$(fm-logo)", + "type": "webview" + }] }, "configuration": { "title": "%settings.configuration.title%", @@ -174,8 +168,7 @@ "frontMatter.content.defaultFileType": { "type": "string", "default": "md", - "oneOf": [ - { + "oneOf": [{ "enum": [ "md", "mdx" @@ -191,8 +184,7 @@ "frontMatter.content.defaultSorting": { "type": "string", "default": "", - "oneOf": [ - { + "oneOf": [{ "enum": [ "LastModifiedAsc", "LastModifiedDesc", @@ -539,8 +531,7 @@ "categories" ], "markdownDescription": "%setting.frontMatter.content.filters.markdownDescription%", - "items": [ - { + "items": [{ "type": "string" }, { @@ -608,8 +599,7 @@ "command": { "$id": "#scriptCommand", "type": "string", - "anyOf": [ - { + "anyOf": [{ "enum": [ "node", "bash", @@ -816,8 +806,7 @@ "title", "file" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -871,8 +860,7 @@ "id", "path" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -1113,29 +1101,26 @@ } } }, - "default": [ - { - "name": "default", - "fileTypes": null, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Caption", - "name": "caption", - "type": "string" - }, - { - "title": "Alt text", - "name": "alt", - "type": "string" - } - ] - } - ], + "default": [{ + "name": "default", + "fileTypes": null, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Caption", + "name": "caption", + "type": "string" + }, + { + "title": "Alt text", + "name": "alt", + "type": "string" + } + ] + }], "scope": "Media" }, "frontMatter.media.supportedMimeTypes": { @@ -1365,8 +1350,7 @@ "default": "", "description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1560,8 +1544,7 @@ "type", "name" ], - "allOf": [ - { + "allOf": [{ "if": { "properties": { "type": { @@ -1769,51 +1752,48 @@ "fields" ] }, - "default": [ - { - "name": "default", - "pageBundle": false, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Description", - "name": "description", - "type": "string" - }, - { - "title": "Publishing date", - "name": "date", - "type": "datetime", - "default": "{{now}}", - "isPublishDate": true - }, - { - "title": "Content preview", - "name": "preview", - "type": "image" - }, - { - "title": "Is in draft", - "name": "draft", - "type": "boolean" - }, - { - "title": "Tags", - "name": "tags", - "type": "tags" - }, - { - "title": "Categories", - "name": "categories", - "type": "categories" - } - ] - } - ], + "default": [{ + "name": "default", + "pageBundle": false, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Description", + "name": "description", + "type": "string" + }, + { + "title": "Publishing date", + "name": "date", + "type": "datetime", + "default": "{{now}}", + "isPublishDate": true + }, + { + "title": "Content preview", + "name": "preview", + "type": "image" + }, + { + "title": "Is in draft", + "name": "draft", + "type": "boolean" + }, + { + "title": "Tags", + "name": "tags", + "type": "tags" + }, + { + "title": "Categories", + "name": "categories", + "type": "categories" + } + ] + }], "scope": "Taxonomy" }, "frontMatter.taxonomy.customTaxonomy": { @@ -1826,8 +1806,7 @@ "type": "string", "description": "%setting.frontMatter.taxonomy.customTaxonomy.items.properties.id.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -2021,16 +2000,10 @@ "frontMatter.website.host": { "type": "string", "markdownDescription": "%setting.frontMatter.website.host.markdownDescription%" - }, - "frontMatter.integration.deepl": { - "type": "string", - "default": "", - "markdownDescription": "%setting.frontMatter.integration.deepl.markdownDescription%" } } }, - "commands": [ - { + "commands": [{ "command": "frontMatter.project.switch", "title": "%command.frontMatter.project.switch%", "category": "Front Matter", @@ -2356,21 +2329,16 @@ } } ], - "submenus": [ - { - "id": "frontmatter.submenu", - "label": "Front Matter" - } - ], + "submenus": [{ + "id": "frontmatter.submenu", + "label": "Front Matter" + }], "menus": { - "webview/context": [ - { - "command": "workbench.action.webview.openDeveloperTools", - "when": "frontMatter:isDevelopment" - } - ], - "editor/title": [ - { + "webview/context": [{ + "command": "workbench.action.webview.openDeveloperTools", + "when": "frontMatter:isDevelopment" + }], + "editor/title": [{ "command": "frontMatter.markup.heading", "group": "navigation@-133", "when": "frontMatter:file:isValid == true && frontMatter:markdown:wysiwyg" @@ -2456,14 +2424,11 @@ "when": "resourceFilename == 'frontmatter.json'" } ], - "explorer/context": [ - { - "submenu": "frontmatter.submenu", - "group": "frontmatter@1" - } - ], - "frontmatter.submenu": [ - { + "explorer/context": [{ + "submenu": "frontmatter.submenu", + "group": "frontmatter@1" + }], + "frontmatter.submenu": [{ "command": "frontMatter.createFromTemplate", "when": "explorerResourceIsFolder", "group": "frontmatter@1" @@ -2479,8 +2444,7 @@ "group": "frontmatter@3" } ], - "commandPalette": [ - { + "commandPalette": [{ "command": "frontMatter.init", "when": "frontMatterCanInit" }, @@ -2633,8 +2597,7 @@ "when": "frontMatter:file:isValid == true" } ], - "view/title": [ - { + "view/title": [{ "command": "frontMatter.chatbot", "group": "navigation@0", "when": "view == frontMatter.explorer" @@ -2666,57 +2629,52 @@ } ] }, - "grammars": [ - { - "path": "./syntaxes/hugo.tmLanguage.json", - "scopeName": "frontmatter.markdown.hugo", - "injectTo": [ - "text.html.markdown" - ] - } - ], - "walkthroughs": [ - { - "id": "frontmatter.welcome", - "title": "Get started with Front Matter", - "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", - "steps": [ - { - "id": "frontmatter.welcome.init", - "title": "Get started", - "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", - "media": { - "markdown": "assets/walkthrough/get-started.md" - }, - "completionEvents": [ - "onContext:frontMatterInitialized" - ] + "grammars": [{ + "path": "./syntaxes/hugo.tmLanguage.json", + "scopeName": "frontmatter.markdown.hugo", + "injectTo": [ + "text.html.markdown" + ] + }], + "walkthroughs": [{ + "id": "frontmatter.welcome", + "title": "Get started with Front Matter", + "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", + "steps": [{ + "id": "frontmatter.welcome.init", + "title": "Get started", + "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", + "media": { + "markdown": "assets/walkthrough/get-started.md" }, - { - "id": "frontmatter.welcome.documentation", - "title": "Documentation", - "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", - "media": { - "markdown": "assets/walkthrough/documentation.md" - }, - "completionEvents": [ - "onLink:https://frontmatter.codes/docs" - ] + "completionEvents": [ + "onContext:frontMatterInitialized" + ] + }, + { + "id": "frontmatter.welcome.documentation", + "title": "Documentation", + "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", + "media": { + "markdown": "assets/walkthrough/documentation.md" }, - { - "id": "frontmatter.welcome.supporter", - "title": "Support the project", - "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", - "media": { - "markdown": "assets/walkthrough/support-the-project.md" - }, - "completionEvents": [ - "onLink:https://github.com/sponsors/estruyf" - ] - } - ] - } - ] + "completionEvents": [ + "onLink:https://frontmatter.codes/docs" + ] + }, + { + "id": "frontmatter.welcome.supporter", + "title": "Support the project", + "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", + "media": { + "markdown": "assets/walkthrough/support-the-project.md" + }, + "completionEvents": [ + "onLink:https://github.com/sponsors/estruyf" + ] + } + ] + }] }, "scripts": { "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", @@ -2845,4 +2803,4 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^2.0.6" } -} +} \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index 3dc8a4dc..597529d7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -268,7 +268,6 @@ "setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.customType.description": "Specify the name of the custom field type to use.", "setting.frontMatter.taxonomy.contentTypes.items.properties.clearEmpty.description": "Specify if the empty values should be cleared.", "setting.frontMatter.website.host.markdownDescription": "Specify the host URL of your website. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.website.url)", - "setting.frontMatter.integration.deepl.markdownDescription": "Specify the DeepL API key to use for the translation of your content. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.integration.deepl)", "command.frontMatter.settings.refresh": "Refresh Front Matter Settings", "setting.frontMatter.config.dynamicFilePath.markdownDescription": "Specify the path to the dynamic config file (ex: [[workspace]]/config.js). [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.config.dynamicfilepath)", "setting.frontMatter.taxonomy.contentTypes.items.properties.allowAsSubContent.description": "Specify if the content type can be used as sub content.", diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 61bd09bf..2dfd6589 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -10,7 +10,7 @@ import { openFileInEditor, parseWinPath } from '../helpers'; -import { COMMAND_NAME, SETTING_CONTENT_I18N, SETTING_INTEGRATION_DEEPL } from '../constants'; +import { COMMAND_NAME, ExtensionState, SETTING_CONTENT_I18N } from '../constants'; import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models'; import { join, parse } from 'path'; import { existsAsync } from '../utils'; @@ -336,7 +336,7 @@ export class i18n { targetLocale: I18nConfig ) { return new Promise(async (resolve) => { - const authKey = Settings.get(SETTING_INTEGRATION_DEEPL); + const authKey = await Extension.getInstance().getSecret(ExtensionState.Secrets.DeeplApiKey); if (!authKey) { resolve(article); return; diff --git a/src/constants/ExtensionState.ts b/src/constants/ExtensionState.ts index d3ae6c70..e8fe9722 100644 --- a/src/constants/ExtensionState.ts +++ b/src/constants/ExtensionState.ts @@ -30,5 +30,9 @@ export const ExtensionState = { v7_0_0: { dateFields: `frontMatter:Updates:v7.0.0:dateFields` } + }, + + Secrets: { + DeeplApiKey: `frontMatter:Secrets:DeeplApiKey` } }; diff --git a/src/constants/GeneralCommands.ts b/src/constants/GeneralCommands.ts index 89a060cd..5b14a453 100644 --- a/src/constants/GeneralCommands.ts +++ b/src/constants/GeneralCommands.ts @@ -17,6 +17,10 @@ export const GeneralCommands = { getBranch: 'getBranch', selectBranch: 'gitSelectBranch' }, + secrets: { + get: 'getSecret', + set: 'setSecret' + }, runCommand: 'runCommand', getLocalization: 'getLocalization', openOnWebsite: 'openOnWebsite' diff --git a/src/constants/settings.ts b/src/constants/settings.ts index adde4dfd..d184a65b 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -114,8 +114,6 @@ export const SETTING_SNIPPETS_WRAPPER = 'snippets.wrapper.enabled'; export const SETTING_WEBSITE_URL = 'website.host'; -export const SETTING_INTEGRATION_DEEPL = 'integration.deepl'; - /** * Sponsors only settings */ diff --git a/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx b/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx new file mode 100644 index 00000000..bc155dfe --- /dev/null +++ b/src/dashboardWebView/components/SettingsView/IntegrationsView.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import * as l10n from '@vscode/l10n'; +import { messageHandler } from '@estruyf/vscode/dist/client'; +import { LocalizationKey } from '../../../localization'; +import { GeneralCommands, ExtensionState } from '../../../constants'; +import { SettingsInput } from './SettingsInput'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; + +export interface IIntegrationsViewProps { } + +export const IntegrationsView: React.FunctionComponent = ({ }: React.PropsWithChildren) => { + const [deeplApiKey, setDeeplApiKey] = React.useState(''); + const [crntDeeplApiKey, setCrntDeeplApiKey] = React.useState(''); + + const onSave = React.useCallback(() => { + messageHandler.request(GeneralCommands.toVSCode.secrets.set, { + key: ExtensionState.Secrets.DeeplApiKey, + value: crntDeeplApiKey + }).then((apiKey: string) => { + setDeeplApiKey(apiKey); + }); + }, [crntDeeplApiKey]); + + const onChange = (_: string, value: string) => { + setCrntDeeplApiKey(value); + }; + + React.useEffect(() => { + messageHandler.request(GeneralCommands.toVSCode.secrets.get, ExtensionState.Secrets.DeeplApiKey).then((apiKey: string) => { + setDeeplApiKey(apiKey); + setCrntDeeplApiKey(apiKey); + }); + }, []); + + return ( +
+
+

{l10n.t(LocalizationKey.settingsIntegrationsViewDeeplTitle)}

+ + + +
+ + {l10n.t(LocalizationKey.commonSave)} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/SettingsView/SettingsView.tsx b/src/dashboardWebView/components/SettingsView/SettingsView.tsx index 2a79ac84..7c316d70 100644 --- a/src/dashboardWebView/components/SettingsView/SettingsView.tsx +++ b/src/dashboardWebView/components/SettingsView/SettingsView.tsx @@ -12,6 +12,7 @@ import { COMMAND_NAME } from '../../../constants'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { VSCodePanelTab, VSCodePanelView, VSCodePanels } from '@vscode/webview-ui-toolkit/react'; import { CommonSettings } from './CommonSettings'; +import { IntegrationsView } from './IntegrationsView'; export interface ISettingsViewProps { } @@ -54,6 +55,8 @@ export const SettingsView: React.FunctionComponent = (_: Rea ) } + {l10n.t(LocalizationKey.settingsViewIntegration)} + @@ -82,6 +85,10 @@ export const SettingsView: React.FunctionComponent = (_: Rea ) } + + + +
) diff --git a/src/helpers/Extension.ts b/src/helpers/Extension.ts index 90d5eb72..16505bda 100644 --- a/src/helpers/Extension.ts +++ b/src/helpers/Extension.ts @@ -424,6 +424,14 @@ export class Extension { } } + public async getSecret(key: string): Promise { + return this.ctx.secrets.get(key); + } + + public async setSecret(key: string, value: string): Promise { + return this.ctx.secrets.store(key, value); + } + public isBetaVersion() { return basename(this.ctx.globalStorageUri.fsPath) === EXTENSION_BETA_ID; } diff --git a/src/listeners/dashboard/SettingsListener.ts b/src/listeners/dashboard/SettingsListener.ts index 030f1102..a242d61f 100644 --- a/src/listeners/dashboard/SettingsListener.ts +++ b/src/listeners/dashboard/SettingsListener.ts @@ -4,6 +4,7 @@ import { Folders } from '../../commands/Folders'; import { COMMAND_NAME, ExtensionState, + GeneralCommands, SETTING_CONTENT_STATIC_FOLDER, SETTING_FRAMEWORK_ID, SETTING_PREVIEW_HOST @@ -61,9 +62,21 @@ export class SettingsListener extends BaseListener { case DashboardMessage.setSettings: this.setConfigSettings(msg); break; + case GeneralCommands.toVSCode.secrets.get: + this.getSecretValue(msg.command, msg.payload, msg.requestId); + break; + case GeneralCommands.toVSCode.secrets.set: + this.setSecretValue(msg.command, msg.payload, msg.requestId); + break; } } + /** + * Retrieves the configuration settings based on the provided payload. + * @param command - The command to execute. + * @param requestId - The ID of the request. + * @param payload - The payload containing the settings to retrieve. + */ public static async getConfigSettings({ command, requestId, payload }: PostMessageData) { if (!command || !requestId || !payload) { return; @@ -96,6 +109,10 @@ export class SettingsListener extends BaseListener { this.sendRequest(command as any, requestId, true); } + /** + * Switches the current project to the specified project. + * @param project - The name of the project to switch to. + */ public static async switchProject(project: string) { if (project) { this.sendMsg(DashboardCommand.loading, 'loading' as LoadingType); @@ -123,6 +140,50 @@ export class SettingsListener extends BaseListener { } } + /** + * Retrieves the secret value for a given command and value. + * @param command - The command to retrieve the secret value for. + * @param key - The key associated with the secret. + * @param requestId - Optional. The ID of the request. + * @returns A Promise that resolves to the secret value. + */ + private static async getSecretValue(command: string, key: string, requestId?: string) { + if (!command || !requestId) { + return; + } + + const extension = Extension.getInstance(); + const value = await extension.getSecret(key); + + this.sendRequest(command as any, requestId, value); + } + + /** + * Sets the secret value for a given key. + * @param command - The command to execute. + * @param key - The key for the secret value. + * @param value - The secret value to set. + * @param requestId - Optional. The request ID. + */ + private static async setSecretValue( + command: string, + { key, value }: { key: string; value: string }, + requestId?: string + ) { + if (!command || !requestId || !key) { + return; + } + + const extension = Extension.getInstance(); + await extension.setSecret(key, value || ''); + + Notifications.info( + l10n.t(LocalizationKey.listenersDashboardSettingsListenerSetSecretValueMessage) + ); + + this.sendRequest(command as any, requestId, true); + } + /** * Update a setting from the dashboard * @param data diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 418c9570..28cc2d66 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -175,6 +175,10 @@ export enum LocalizationKey { * Astro */ settingsViewAstro = 'settings.view.astro', + /** + * Integration + */ + settingsViewIntegration = 'settings.view.integration', /** * Open dashboard on startup */ @@ -219,6 +223,10 @@ export enum LocalizationKey { * Read more about Git submodules */ settingsGitSubmoduleLink = 'settings.git.submoduleLink', + /** + * Integration + */ + settingsIntegrationTitle = 'settings.integration.title', /** * Website and SSG settings */ @@ -235,6 +243,18 @@ export enum LocalizationKey { * SSG/Framework start command */ settingsCommonSettingsStartCommand = 'settings.commonSettings.startCommand', + /** + * DeepL + */ + settingsIntegrationsViewDeeplTitle = 'settings.integrationsView.deepl.title', + /** + * Authentication key + */ + settingsIntegrationsViewDeeplIntputLabel = 'settings.integrationsView.deepl.intput.label', + /** + * Enter your DeepL authentication key + */ + settingsIntegrationsViewDeeplIntputPlaceholder = 'settings.integrationsView.deepl.intput.placeholder', /** * Developer mode */ @@ -2408,6 +2428,10 @@ export enum LocalizationKey { * Failed to initialize the template. */ listenersDashboardSettingsListenerTriggerTemplateInitError = 'listeners.dashboard.settingsListener.triggerTemplate.init.error', + /** + * Setting has been updated. + */ + listenersDashboardSettingsListenerSetSecretValueMessage = 'listeners.dashboard.settingsListener.setSecretValue.message', /** * Snippet missing title or body */