feat: add smart rename to sync filename with front matter

Add a 'Smart Rename' feature that regenerates the filename from current
front matter values (title and publish date) using the same logic as
content creation. This helps users keep filenames in sync when they
change the title or publish date after file creation.

Changes:
- Add ArticleHelper.smartRename() core logic with page bundle support
- Add SmartRenameAction button to the panel webview Actions section
- Add smart rename option to dashboard content actions dropdown
- Add CommandToCode.smartRename and DashboardMessage.smartRename
- Add PanelAction type support for disabling the action
- Add localization keys for all UI strings

Closes #545

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Elio Struyf
2026-03-30 10:46:05 +02:00
parent c49d3ef00f
commit c4ee4cfc09
11 changed files with 225 additions and 2 deletions
+9
View File
@@ -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",
+1
View File
@@ -32,6 +32,7 @@ export enum DashboardMessage {
pinItem = 'pinItem',
unpinItem = 'unpinItem',
rename = 'rename',
smartRename = 'smartRename',
moveFile = 'moveFile',
// Media Dashboard
@@ -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';
@@ -75,6 +76,11 @@ export const ContentActions: React.FunctionComponent<IContentActionsProps> = ({
messageHandler.send(DashboardMessage.rename, path);
}, [path])
const onSmartRename = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
messageHandler.send(DashboardMessage.smartRename, path);
}, [path])
const onOpenWebsite = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
openOnWebsite(settings?.websiteUrl, path);
@@ -144,6 +150,11 @@ export const ContentActions: React.FunctionComponent<IContentActionsProps> = ({
<span>{l10n.t(LocalizationKey.commonRename)}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onSmartRename}>
<ArrowPathIcon className={`mr-2 h-4 w-4`} aria-hidden={true} />
<span>{l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemSmartRename)}</span>
</DropdownMenuItem>
{
settings?.websiteUrl && (
<DropdownMenuItem onClick={onOpenWebsite}>
+140
View File
@@ -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<string>(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
+3
View File
@@ -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;
}
}
+3
View File
@@ -25,6 +25,9 @@ export class ArticleListener extends BaseListener {
case CommandToCode.publish:
Article.toggleDraft();
break;
case CommandToCode.smartRename:
ArticleHelper.smartRename();
break;
}
}
+24
View File
@@ -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
*/
+1
View File
@@ -38,6 +38,7 @@ export type PanelAction =
| 'openDashboard'
| 'createContent'
| 'optimizeSlug'
| 'smartRename'
| 'preview'
| 'openOnWebsite'
| 'startStopServer'
+2 -1
View File
@@ -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'
}
+5
View File
@@ -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<IActionsProps> = ({
allActions.push(<SlugAction key="optimizeSlug" />);
}
if (metadata?.title && !disableActions.includes(`smartRename`)) {
allActions.push(<SmartRenameAction key="smartRename" />);
}
if (settings?.preview?.host && !disableActions.includes(`preview`)) {
if ((metadata && typeof metadata.slug !== "undefined") || !metadata) {
allActions.push(<Preview key="preview" />);
@@ -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 (
<ActionButton onClick={smartRename} title={l10n.t(LocalizationKey.panelSmartRenameActionTitle)}>
{l10n.t(LocalizationKey.panelSmartRenameActionTitle)}
</ActionButton>
);
};
SmartRenameAction.displayName = 'SmartRenameAction';
export { SmartRenameAction };