diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbf7995..ba19b915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### 🎨 Enhancements +- [#545](https://github.com/estruyf/vscode-front-matter/issues/545): Add smart rename action to sync filename with front matter data - [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites *WIP* - [#965](https://github.com/estruyf/vscode-front-matter/issues/965): Added SEO support for the keyword in the first paragraph - [#973](https://github.com/estruyf/vscode-front-matter/issues/973): Support for number fields in the snippets diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 90499486..76f21207 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -524,6 +524,8 @@ "panel.slugAction.title": "Optimize slug", + "panel.smartRenameAction.title": "Smart rename", + "panel.spinner.loading": "Loading...", "panel.startServerbutton.start": "Start server", @@ -550,6 +552,13 @@ "commands.article.rename.fileName.title": "Rename: {0}", "commands.article.rename.fileName.prompt": "File name", + "commands.article.smartRename.alreadyInSync": "The filename is already in sync with the front matter", + "commands.article.smartRename.success": "File renamed from \"{0}\" to \"{1}\"", + "commands.article.smartRename.unableToGenerate": "Unable to generate a new filename from the front matter", + "commands.article.smartRename.fileExists.error": "A file with the name \"{0}\" already exists", + + "dashboard.contents.contentActions.menuItem.smartRename": "Smart rename", + "commands.cache.cleared": "Cache cleared", "commands.chatbot.title": "Ask me anything", diff --git a/package.json b/package.json index 6bc35600..05f7cbb7 100644 --- a/package.json +++ b/package.json @@ -2856,7 +2856,7 @@ ] }, "scripts": { - "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", + "dev": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", "vscode:prepublish": "npm run clean && npm run localization:generate && npm-run-all --parallel prod:*", "build:ext": "npm run clean && npm-run-all --parallel dev:build:*", "watch:ext": "webpack --mode development --watch --config ./webpack/extension.config.js", diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index 53201438..8db1af4a 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -32,6 +32,7 @@ export enum DashboardMessage { pinItem = 'pinItem', unpinItem = 'unpinItem', rename = 'rename', + smartRename = 'smartRename', moveFile = 'moveFile', // Media Dashboard diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 06b19fc8..3a0b83de 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -5,7 +5,8 @@ import { TrashIcon, LanguageIcon, EllipsisHorizontalIcon, - ArrowRightCircleIcon + ArrowRightCircleIcon, + ArrowPathIcon } from '@heroicons/react/24/outline'; import * as React from 'react'; import { CustomScript, I18nConfig } from '../../../models'; @@ -73,7 +74,12 @@ export const ContentActions: React.FunctionComponent = ({ const onRename = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); messageHandler.send(DashboardMessage.rename, path); - }, [path]) + }, [path]); + + const onSmartRename = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + messageHandler.send(DashboardMessage.smartRename, path); + }, [path]); const onOpenWebsite = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -144,6 +150,11 @@ export const ContentActions: React.FunctionComponent = ({ {l10n.t(LocalizationKey.commonRename)} + + + {l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemSmartRename)} + + { settings?.websiteUrl && ( diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 0c668202..d8050020 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -236,6 +236,146 @@ export class ArticleHelper { }); } + /** + * Smart rename a file based on its front matter title and publish date. + * Regenerates the expected filename using the same logic as content creation. + * @param filePath - The path of the file to be renamed. + */ + public static async smartRename(filePath?: string) { + if (!filePath) { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + filePath = editor.document.uri.fsPath; + } + + filePath = parseWinPath(filePath); + const fileUri = Uri.file(filePath); + + const article = await ArticleHelper.getFrontMatterByPath(filePath); + if (!article || !article.data) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleRenameFileNotExistsError) + ); + return; + } + + const titleField = getTitleField(); + const title: string = article.data[titleField]; + if (!title) { + Notifications.warning( + l10n.t(LocalizationKey.commandsArticleSmartRenameUnableToGenerate) + ); + return; + } + + const contentType = await ArticleHelper.getContentType(article); + const articleDate = await ArticleHelper.getDate(article); + + let filePrefix = Settings.get(SETTING_TEMPLATES_PREFIX); + filePrefix = await ArticleHelper.getFilePrefix( + filePrefix, + filePath, + contentType, + title, + articleDate + ); + + const sanitizedName = ArticleHelper.sanitize(title); + const parsed = parseFile(filePath); + const folderPath = dirname(fileUri.fsPath); + + let newFileName: string; + if (contentType?.pageBundle) { + // For page bundles, the folder name should be updated + if (filePrefix && typeof filePrefix === 'string') { + if (filePrefix.endsWith('/')) { + newFileName = `${filePrefix}${sanitizedName}`; + } else { + newFileName = `${filePrefix}-${sanitizedName}`; + } + } else { + newFileName = sanitizedName; + } + + const parentFolder = dirname(folderPath); + const currentFolderName = parseFile(folderPath).base; + + if (currentFolderName === newFileName) { + Notifications.info( + l10n.t(LocalizationKey.commandsArticleSmartRenameAlreadyInSync) + ); + return; + } + + const newFolderPath = join(parentFolder, newFileName); + if (await existsAsync(newFolderPath)) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleSmartRenameFileExistsError, newFileName) + ); + return; + } + + await workspace.fs.rename(Uri.file(folderPath), Uri.file(newFolderPath), { + overwrite: false + }); + + Notifications.info( + l10n.t( + LocalizationKey.commandsArticleSmartRenameSuccess, + currentFolderName, + newFileName + ) + ); + } else { + // For regular files, rename the file + let newFileBase = `${sanitizedName}${parsed.ext}`; + if (filePrefix && typeof filePrefix === 'string') { + if (filePrefix.endsWith('/')) { + newFileBase = `${filePrefix}${newFileBase}`; + } else { + newFileBase = `${filePrefix}-${newFileBase}`; + } + } + + if (parsed.base === newFileBase) { + Notifications.info( + l10n.t(LocalizationKey.commandsArticleSmartRenameAlreadyInSync) + ); + return; + } + + const newFileUri = Uri.joinPath(Uri.file(folderPath), newFileBase); + if (await existsAsync(newFileUri.fsPath)) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleSmartRenameFileExistsError, newFileBase) + ); + return; + } + + // Close the document if it's open in an editor before renaming + const openEditors = vscode.window.visibleTextEditors.filter( + (e) => parseWinPath(e.document.uri.fsPath) === filePath + ); + for (const editor of openEditors) { + await editor.document.save(); + } + + await workspace.fs.rename(fileUri, newFileUri, { + overwrite: false + }); + + Notifications.info( + l10n.t( + LocalizationKey.commandsArticleSmartRenameSuccess, + parsed.base, + newFileBase + ) + ); + } + } + /** * Generate the update to be applied to the article. * @param article diff --git a/src/listeners/dashboard/PagesListener.ts b/src/listeners/dashboard/PagesListener.ts index da955a25..ff7479f9 100644 --- a/src/listeners/dashboard/PagesListener.ts +++ b/src/listeners/dashboard/PagesListener.ts @@ -74,6 +74,9 @@ export class PagesListener extends BaseListener { case DashboardMessage.moveFile: await this.moveFile(msg.payload); break; + case DashboardMessage.smartRename: + ArticleHelper.smartRename(msg.payload); + break; } } diff --git a/src/listeners/panel/ArticleListener.ts b/src/listeners/panel/ArticleListener.ts index 3bd09dca..9fce64f0 100644 --- a/src/listeners/panel/ArticleListener.ts +++ b/src/listeners/panel/ArticleListener.ts @@ -25,6 +25,9 @@ export class ArticleListener extends BaseListener { case CommandToCode.publish: Article.toggleDraft(); break; + case CommandToCode.smartRename: + ArticleHelper.smartRename(); + break; } } diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 312eda56..46145bc6 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1696,6 +1696,10 @@ export enum LocalizationKey { * Optimize slug */ panelSlugActionTitle = 'panel.slugAction.title', + /** + * Smart rename + */ + panelSmartRenameActionTitle = 'panel.smartRenameAction.title', /** * Loading... */ @@ -1772,6 +1776,26 @@ export enum LocalizationKey { * File name */ commandsArticleRenameFileNamePrompt = 'commands.article.rename.fileName.prompt', + /** + * The filename is already in sync with the front matter + */ + commandsArticleSmartRenameAlreadyInSync = 'commands.article.smartRename.alreadyInSync', + /** + * File renamed from "{0}" to "{1}" + */ + commandsArticleSmartRenameSuccess = 'commands.article.smartRename.success', + /** + * Unable to generate a new filename from the front matter + */ + commandsArticleSmartRenameUnableToGenerate = 'commands.article.smartRename.unableToGenerate', + /** + * A file with the name "{0}" already exists + */ + commandsArticleSmartRenameFileExistsError = 'commands.article.smartRename.fileExists.error', + /** + * Smart rename + */ + dashboardContentsContentActionsMenuItemSmartRename = 'dashboard.contents.contentActions.menuItem.smartRename', /** * Cache cleared */ diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 30a1495f..c567751c 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -38,6 +38,7 @@ export type PanelAction = | 'openDashboard' | 'createContent' | 'optimizeSlug' + | 'smartRename' | 'preview' | 'openOnWebsite' | 'startStopServer' diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 1063c61b..b5307bdb 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -48,5 +48,6 @@ export enum CommandToCode { searchByType = 'search-by-type', processMediaData = 'process-media-data', isServerStarted = 'is-server-started', - runFieldAction = 'run-field-action' + runFieldAction = 'run-field-action', + smartRename = 'smart-rename' } diff --git a/src/panelWebView/components/Actions.tsx b/src/panelWebView/components/Actions.tsx index 8bf30669..2bf055bc 100644 --- a/src/panelWebView/components/Actions.tsx +++ b/src/panelWebView/components/Actions.tsx @@ -4,6 +4,7 @@ import { Collapsible } from './Collapsible'; import { CustomScript } from './CustomScript'; import { Preview } from './Preview'; import { SlugAction } from './SlugAction'; +import { SmartRenameAction } from './SmartRenameAction'; import { StartServerButton } from './StartServerButton'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../localization'; @@ -52,6 +53,10 @@ const Actions: React.FunctionComponent = ({ allActions.push(); } + if (metadata?.title && !disableActions.includes(`smartRename`)) { + allActions.push(); + } + if (settings?.preview?.host && !disableActions.includes(`preview`)) { if ((metadata && typeof metadata.slug !== "undefined") || !metadata) { allActions.push(); diff --git a/src/panelWebView/components/SmartRenameAction.tsx b/src/panelWebView/components/SmartRenameAction.tsx new file mode 100644 index 00000000..6f178d0f --- /dev/null +++ b/src/panelWebView/components/SmartRenameAction.tsx @@ -0,0 +1,25 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import * as React from 'react'; +import { CommandToCode } from '../CommandToCode'; +import { ActionButton } from './ActionButton'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../../localization'; + +export interface ISmartRenameActionProps { } + +const SmartRenameAction: React.FunctionComponent< + ISmartRenameActionProps +> = () => { + const smartRename = () => { + Messenger.send(CommandToCode.smartRename); + }; + + return ( + + {l10n.t(LocalizationKey.panelSmartRenameActionTitle)} + + ); +}; + +SmartRenameAction.displayName = 'SmartRenameAction'; +export { SmartRenameAction };