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/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index af9668d8..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", @@ -90,6 +97,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 +137,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 +535,17 @@ "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.i18n.translate.progress.title": "Translating content...", + "commands.preview.panel.title": "Preview: {0}", "commands.preview.askUserToPickFolder.title": "Select the folder of the article to preview", @@ -712,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 5f9cafde..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", @@ -311,6 +303,17 @@ "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%" + }, + "locales": { + "type": "array", + "description": "%setting.frontMatter.content.pageFolders.items.properties.locales.description%", + "items": { + "$ref": "#i18n" + } } }, "additionalProperties": false, @@ -321,6 +324,34 @@ }, "scope": "Content" }, + "frontMatter.content.i18n": { + "type": "array", + "default": [], + "markdownDescription": "%setting.frontMatter.content.i18n.markdownDescription%", + "items": { + "$id": "#i18n", + "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" + ] + }, + "scope": "Content" + }, "frontMatter.content.placeholders": { "type": "array", "default": [], @@ -500,8 +531,7 @@ "categories" ], "markdownDescription": "%setting.frontMatter.content.filters.markdownDescription%", - "items": [ - { + "items": [{ "type": "string" }, { @@ -569,8 +599,7 @@ "command": { "$id": "#scriptCommand", "type": "string", - "anyOf": [ - { + "anyOf": [{ "enum": [ "node", "bash", @@ -777,8 +806,7 @@ "title", "file" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -832,8 +860,7 @@ "id", "path" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -1074,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": { @@ -1326,8 +1350,7 @@ "default": "", "description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1521,8 +1544,7 @@ "type", "name" ], - "allOf": [ - { + "allOf": [{ "if": { "properties": { "type": { @@ -1730,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": { @@ -1787,8 +1806,7 @@ "type": "string", "description": "%setting.frontMatter.taxonomy.customTaxonomy.items.properties.id.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1985,8 +2003,7 @@ } } }, - "commands": [ - { + "commands": [{ "command": "frontMatter.project.switch", "title": "%command.frontMatter.project.switch%", "category": "Front Matter", @@ -2301,23 +2318,27 @@ "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", + "icon": { + "light": "assets/icons/i18n-light.svg", + "dark": "assets/icons/i18n-dark.svg" + } } ], + "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" @@ -2347,6 +2368,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", @@ -2398,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" @@ -2421,8 +2444,7 @@ "group": "frontmatter@3" } ], - "commandPalette": [ - { + "commandPalette": [{ "command": "frontMatter.init", "when": "frontMatterCanInit" }, @@ -2450,6 +2472,10 @@ "command": "frontMatter.git.sync", "when": "frontMatter:git:enabled" }, + { + "command": "frontMatter.i18n.create", + "when": "frontMatter:i18n:default" + }, { "command": "frontMatter.collapseSections", "when": "false" @@ -2571,8 +2597,7 @@ "when": "frontMatter:file:isValid == true" } ], - "view/title": [ - { + "view/title": [{ "command": "frontMatter.chatbot", "group": "navigation@0", "when": "view == frontMatter.explorer" @@ -2604,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:*", @@ -2783,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 3127bd29..597529d7 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,12 @@ "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.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/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 new file mode 100644 index 00000000..2dfd6589 --- /dev/null +++ b/src/commands/i18n.ts @@ -0,0 +1,517 @@ +import { ProgressLocation, Uri, commands, window, workspace } from 'vscode'; +import { + ArticleHelper, + ContentType, + Extension, + FrameworkDetector, + Logger, + Notifications, + Settings, + openFileInEditor, + parseWinPath +} from '../helpers'; +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'; +import { Folders } from '.'; +import { ParsedFrontMatter } from '../parsers'; +import { PagesListener } from '../listeners/dashboard'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../localization'; + +export class i18n { + private static processedFiles: { + [filePath: string]: { dir: string; filename: string; isPageBundle: boolean }; + } = {}; + + /** + * Registers the i18n commands. + */ + public static register() { + 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 = {}; + } + + /** + * 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) { + pageFolder = await i18n.getPageFolder(filePath); + } + + 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 = await i18n.getFileInfo(filePath); + + if (pageFolder.path) { + let pageFolderPath = parseWinPath(pageFolder.path); + if (!pageFolderPath.endsWith('/')) { + pageFolderPath += '/'; + } + + return ( + parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).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; + } + + 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(pageFolderPath).toLowerCase() + ) { + return i18nSettings.find((i18n) => i18n.locale === pageFolder?.defaultLocale); + } + } + + pageFolder = await i18n.getPageFolder(filePath); + if (!pageFolder) { + return; + } + + 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; + } + } + } + + return; + } + + /** + * 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]: { + locale: I18nConfig; + path: string; + }; + } + | undefined + > { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return; + } + + const translations: { + [locale: string]: { + locale: I18nConfig; + path: string; + }; + } = {}; + + 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 || '', fileInfo.filename); + if (await existsAsync(translation)) { + translations[i18n.locale] = { + locale: i18n, + path: translation + }; + } + } + return translations; + } + + pageFolder = await i18n.getPageFolder(filePath); + if (!pageFolder) { + return translations; + } + + for (const i18n of i18nSettings) { + const translation = join(pageFolder.path, i18n.path || '', fileInfo.filename); + if (await existsAsync(translation)) { + translations[i18n.locale] = { + locale: i18n, + path: 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 | string) { + if (!fileUri) { + const filePath = ArticleHelper.getActiveFile(); + fileUri = filePath ? Uri.file(filePath) : undefined; + } + + if (!fileUri) { + 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(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); + return; + } + + const isDefaultLanguage = await i18n.isDefaultLanguage(fileUri.fsPath); + if (!isDefaultLanguage) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNotDefaultLocale)); + return; + } + + const locale = await window.showQuickPick( + i18nSettings.filter((i18n) => i18n.path).map((i18n) => i18n.title || i18n.locale), + { + title: l10n.t(LocalizationKey.commandsI18nCreateQuickPickTitle), + placeHolder: l10n.t(LocalizationKey.commandsI18nCreateQuickPickPlaceHolder), + ignoreFocusOut: true + } + ); + + if (!locale) { + return; + } + + const selectedI18n = i18nSettings.find( + (i18n) => i18n.title === locale || i18n.locale === locale + ); + if (!selectedI18n || !selectedI18n.path) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoConfig)); + return; + } + + let article = await ArticleHelper.getFrontMatterByPath(fileUri.fsPath); + if (!article) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoFile)); + return; + } + + const contentType = ArticleHelper.getContentType(article); + if (!contentType) { + Notifications.warning(l10n.t(LocalizationKey.commandsI18nCreateWarningNoContentType)); + return; + } + + // Get the directory of the file + const fileInfo = parse(fileUri.fsPath); + 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)); + } + + article = await i18n.updateFrontMatter( + article, + fileUri.fsPath, + contentType, + selectedI18n, + i18nDir + ); + + const newFilePath = join(i18nDir, fileInfo.base); + if (await existsAsync(newFilePath)) { + Notifications.error(l10n.t(LocalizationKey.commandsI18nCreateErrorFileExists)); + 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, + Buffer.from(ArticleHelper.stringifyFrontMatter(article.content, article.data)) + ); + + await openFileInEditor(newFilePath); + + PagesListener.refresh(); + + Notifications.info( + l10n.t( + LocalizationKey.commandsI18nCreateSuccessCreated, + selectedI18n.title || selectedI18n.locale + ) + ); + } + + /** + * 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 = await Extension.getInstance().getSecret(ExtensionState.Secrets.DeeplApiKey); + 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. + * @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 }> { + if (i18n.processedFiles[filePath]) { + return i18n.processedFiles[filePath]; + } + + 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 += '/'; + } + + i18n.processedFiles[filePath] = { + isPageBundle, + filename, + dir + }; + + return i18n.processedFiles[filePath]; + } + + /** + * 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. + * + * @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/components/shadcn/Dropdown.tsx b/src/components/shadcn/Dropdown.tsx index 20cc43d7..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 @@ -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/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/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 e761c335..5b14a453 100644 --- a/src/constants/GeneralCommands.ts +++ b/src/constants/GeneralCommands.ts @@ -17,6 +17,11 @@ export const GeneralCommands = { getBranch: 'getBranch', selectBranch: 'gitSelectBranch' }, + secrets: { + get: 'getSecret', + set: 'setSecret' + }, + runCommand: 'runCommand', getLocalization: 'getLocalization', openOnWebsite: 'openOnWebsite' } 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/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/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 1477e6af..b4ae10e9 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,49 @@ 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); + + if (otherLocales.length === 0) { + return null; + } + + 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 ( <>
= ({ ) } - +
)} - + {l10n.t(LocalizationKey.dashboardContentsContentActionsActionMenuButtonTitle)} @@ -161,6 +229,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 new file mode 100644 index 00000000..0e63814a --- /dev/null +++ b/src/dashboardWebView/components/Contents/I18nLabel.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Page } from '../../models'; +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; +} + +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..ba1edd16 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 { } @@ -69,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 && typeof pageData[draftField.name] !== "undefined" ? : 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]); @@ -82,8 +111,7 @@ export const Item: React.FunctionComponent = ({
-
- { - statusHtml ? ( -
- ) : ( - cardFields?.state && draftField && draftField.name && - ) - } - - { - dateHtml ? ( -
- ) : ( - cardFields?.date && - ) - } -
+ { + (statusPlaceholder || datePlaceholder) && ( +
+ {statusPlaceholder} + {datePlaceholder} +
+ ) + } + + - + { + (escapedDescription || descriptionHtml) && ( + + ) + } { tagsHtml ? ( diff --git a/src/dashboardWebView/components/Filters/LanguageFilter.tsx b/src/dashboardWebView/components/Filters/LanguageFilter.tsx new file mode 100644 index 00000000..1c678f3d --- /dev/null +++ b/src/dashboardWebView/components/Filters/LanguageFilter.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator } from '../../../components/shadcn/Dropdown'; +import { LanguageIcon } from '@heroicons/react/24/outline'; +import { MenuButton, MenuItem } from '../Menu'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { DEFAULT_LOCALE_STATE, LocaleAtom, LocalesAtom } from '../../state'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../../../localization'; + +export interface ILanguageFilterProps { } + +export const LanguageFilter: React.FunctionComponent = ({ }: React.PropsWithChildren) => { + const locales = useRecoilValue(LocalesAtom); + const [crntLocale, setCrntLocale] = useRecoilState(LocaleAtom); + + const crntLocaleName = React.useMemo(() => { + if (!crntLocale || !locales || locales.length === 0) { + return null; + } + + const locale = locales.find(locale => locale.locale === crntLocale); + + return locale?.title || locale?.locale; + }, [crntLocale, locales]); + + if (!locales || locales.length <= 1) { + return null; + } + + return ( + + + + {l10n.t(LocalizationKey.dashboardFiltersLanguageFilterLabel)} + + } + title={crntLocaleName || l10n.t(LocalizationKey.dashboardFiltersLanguageFilterAll)} + /> + + + setCrntLocale(DEFAULT_LOCALE_STATE)} + /> + + + + { + locales.map((locale) => ( + setCrntLocale(value)} + /> + )) + } + + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Header/ClearFilters.tsx b/src/dashboardWebView/components/Header/ClearFilters.tsx index 4837af1c..7abfa074 100644 --- a/src/dashboardWebView/components/Header/ClearFilters.tsx +++ b/src/dashboardWebView/components/Header/ClearFilters.tsx @@ -12,7 +12,9 @@ import { CategoryAtom, DEFAULT_TAG_STATE, DEFAULT_CATEGORY_STATE, - FiltersAtom + FiltersAtom, + LocaleAtom, + DEFAULT_LOCALE_STATE } from '../../state'; import { DefaultValue } from 'recoil'; import { useEffect, useMemo } from 'react'; @@ -34,12 +36,14 @@ export const ClearFilters: React.FunctionComponent = ( const folder = useRecoilValue(FolderSelector); const tag = useRecoilValue(TagSelector); const category = useRecoilValue(CategorySelector); + const locale = useRecoilValue(LocaleAtom); const filters = useRecoilValue(FiltersAtom); const resetSorting = useResetRecoilState(SortingAtom); const resetFolder = useResetRecoilState(FolderAtom); const resetTag = useResetRecoilState(TagAtom); const resetCategory = useResetRecoilState(CategoryAtom); + const resetLocale = useResetRecoilState(LocaleAtom); const resetFilters = useResetRecoilState(FiltersAtom); const reset = () => { @@ -48,6 +52,7 @@ export const ClearFilters: React.FunctionComponent = ( resetFolder(); resetTag(); resetCategory(); + resetLocale(); resetFilters(); }; @@ -61,19 +66,20 @@ export const ClearFilters: React.FunctionComponent = ( folder !== DEFAULT_FOLDER_STATE || tag !== DEFAULT_TAG_STATE || category !== DEFAULT_CATEGORY_STATE || + locale !== DEFAULT_LOCALE_STATE || hasCustomFilters ) { setShow(true); } else { setShow(false); } - }, [folder, tag, category, hasCustomFilters]); + }, [folder, tag, category, locale, hasCustomFilters]); if (!show) return null; return (
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/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/dashboardWebView/hooks/usePages.tsx b/src/dashboardWebView/hooks/usePages.tsx index 85d374a1..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,6 +126,24 @@ export default function usePages(pages: Page[]) { let crntPages: Page[] = Object.assign([], pages); + // 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); draftTypes[Tab.All] = crntPages.length; @@ -190,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] ); /** @@ -235,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/models/Page.ts b/src/dashboardWebView/models/Page.ts index 2d20f3e0..034bdb66 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,16 @@ export interface Page { fmContentType: string; fmDateFormat: string | undefined; + // i18n fields + fmDefaultLocale?: boolean; + fmLocale?: I18nConfig; + fmTranslations?: { + [locale: string]: { + locale: I18nConfig; + path: string; + } + }; + title: string; slug: string; date: string | Date; 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/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/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 03c8de9d..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'; @@ -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 * @@ -282,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 */ @@ -370,21 +419,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/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( 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/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/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/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..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 */ @@ -315,6 +335,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 +455,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 +1708,46 @@ 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', + /** + * Translating content... + */ + commandsI18nTranslateProgressTitle = 'commands.i18n.translate.progress.title', /** * Preview: {0} */ @@ -2352,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 */ 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 new file mode 100644 index 00000000..1e472c1f --- /dev/null +++ b/src/models/i18nConfig.ts @@ -0,0 +1,5 @@ +export interface I18nConfig { + locale: string; + title?: 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'; diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index 06e95227..d3195d2d 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'; @@ -76,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 @@ -209,6 +211,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 +236,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,