Merge pull request #1060 from estruyf/beta

Release 10.12.0
This commit is contained in:
Elio Struyf authored and GitHub committed 2026-08-21 16:19:57 +02:00
commit 9d74f1c661
35 files changed
+2636 -846

No files matched your search

+10 -1
View File
@@ -1 +1,10 @@
{"assets":{"v7.0.0":{"snippets-dashboard.png":{"caption":"Snippets dashboard","alt":"Snippets dashboard"}}}}
{
"assets": {
"v7.0.0": {
"snippets-dashboard.png": {
"alt": "Snippets dashboard",
"caption": "Snippets dashboard"
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
{}
{}
+1 -1
View File
@@ -1 +1 @@
{}
{}
+14
View File
@@ -1,5 +1,19 @@
# Change Log
## [10.12.0] - 2026-08-21 - [Release notes](https://beta.frontmatter.codes/updates/v10.12.0)
### 🎨 Enhancements
- [#1053](https://github.com/estruyf/vscode-front-matter/issues/1053): Support pasting images directly into Markdown/MDX files
- [#1055](https://github.com/estruyf/vscode-front-matter/issues/1055): Added the `translate` property on content type fields to define which front matter fields need to be machine translated. The title and description fields are translated by default, set `translate: false` to exclude them
- The `.frontmatter/database` files are now stored in a human readable format with sorted keys, so that Git can merge them line by line instead of conflicting on a single line. Existing files are reformatted once, when the extension activates
### 🐞 Fixes
- [#1043](https://github.com/estruyf/vscode-front-matter/issues/1043): Fix issue with last modified date in content health freshness checks
- [#1054](https://github.com/estruyf/vscode-front-matter/issues/1054): Fix the "Could not retrieve the locale for the current file" error for multilingual content nested in subfolders (ex. `content/de/posts/2024/07/my-post/index.md`). Translations are now created with the same folder structure as the source file
- Show an error notification when one of the `.frontmatter/database` files contains invalid JSON or unresolved Git conflict markers, instead of failing silently
## [10.11.0] - 2026-07-02 - [Release notes](https://beta.frontmatter.codes/updates/v10.11.0)
### 🎨 Enhancements
+18 -1
View File
@@ -236,6 +236,18 @@
"dashboard.media.dialog.title": "View details",
"dashboard.media.panel.close": "Close panel",
"dashboard.media.paste.title": "Paste media",
"dashboard.media.paste.description": "Browse to the folder you want to store the image in.",
"dashboard.media.paste.storeIn": "Store in: {0}",
"dashboard.media.paste.noFolder": "Select a folder to store the image in.",
"dashboard.media.paste.store": "Store image",
"dashboard.media.paste.storing": "Storing the image...",
"dashboard.media.paste.error": "The image could not be stored. Please try again.",
"dashboard.media.paste.saveAndInsert": "Save and insert",
"dashboard.media.paste.snippetTitle": "Insert the image",
"dashboard.media.paste.snippetDescription": "Select how you want to insert the image into your content.",
"dashboard.media.paste.snippetNone": "Without snippet",
"dashboard.media.metadata.panel.title": "Update metadata",
"dashboard.media.metadata.panel.description": "Please specify the metadata you want to set for the file.",
"dashboard.media.metadata.panel.field.fileName": "Filename",
@@ -751,6 +763,8 @@
"helpers.mediaLibrary.remove.warning": "The name \"{0}\" already exists at the file location.",
"helpers.mediaLibrary.remove.error": "Sorry, something went wrong updating \"{0}\" to \"{1}\".",
"helpers.jsonDbHelper.conflict.error": "The \"{0}\" file still contains Git conflict markers. Resolve the conflict in the file to make Front Matter read it again.",
"helpers.jsonDbHelper.parse.error": "The \"{0}\" file could not be read, as it does not contain valid JSON: {1}",
"helpers.openFileInEditor.error": "Couldn't open the file.",
@@ -856,5 +870,8 @@
"services.sponsorAi.getDescription.warning": "The AI description generation took too long. Please try again later.",
"services.sponsorAi.getTaxonomySuggestions.warning": "The AI taxonomy generation took too long. Please try again later.",
"services.terminal.openLocalServerTerminal.terminalOption.message": "Starting local server"
"services.terminal.openLocalServerTerminal.terminalOption.message": "Starting local server",
"providers.mediaPaste.title": "Insert media with Front Matter",
"providers.mediaPaste.saveFailed": "Front Matter: the pasted media could not be stored in \"{0}\"."
}
+1107 -710
View File
File diff suppressed because it is too large. Load diff
+35 -4
View File
@@ -3,7 +3,7 @@
"displayName": "Front Matter CMS",
"description": "Front Matter is a CMS that runs within Visual Studio Code. It gives you the power and control of a full-blown CMS while also providing you the flexibility and speed of the static site generator of your choice like: Hugo, Jekyll, Docusaurus, NextJs, Gatsby, and many more...",
"icon": "assets/frontmatter-teal-128x128.png",
"version": "10.11.0",
"version": "10.12.0",
"preview": false,
"publisher": "eliostruyf",
"galleryBanner": {
@@ -27,7 +27,7 @@
},
"qna": "https://github.com/estruyf/vscode-front-matter/discussions",
"engines": {
"vscode": "^1.90.0"
"vscode": "^1.125.0"
},
"l10n": "./l10n",
"categories": [
@@ -1236,6 +1236,33 @@
},
"scope": "Media"
},
"frontMatter.media.pasteBehavior": {
"type": "string",
"default": "dashboard",
"enum": [
"dashboard",
"auto",
"disabled"
],
"enumDescriptions": [
"%setting.frontMatter.media.pasteBehavior.dashboard.description%",
"%setting.frontMatter.media.pasteBehavior.auto.description%",
"%setting.frontMatter.media.pasteBehavior.disabled.description%"
],
"markdownDescription": "%setting.frontMatter.media.pasteBehavior.markdownDescription%",
"scope": "Media"
},
"frontMatter.media.pasteFolder": {
"type": "string",
"markdownDescription": "%setting.frontMatter.media.pasteFolder.markdownDescription%",
"scope": "Media"
},
"frontMatter.media.pasteFileName": {
"type": "string",
"default": "image-{date}-{time}",
"markdownDescription": "%setting.frontMatter.media.pasteFileName.markdownDescription%",
"scope": "Media"
},
"frontMatter.panel.openOnSupportedFile": {
"type": "boolean",
"default": false,
@@ -1600,6 +1627,10 @@
"default": false,
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.encodeEmoji.description%"
},
"translate": {
"type": "boolean",
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.translate.description%"
},
"dateFormat": {
"type": "string",
"default": "",
@@ -2944,7 +2975,7 @@
},
"devDependencies": {
"@actions/core": "^1.10.0",
"@estruyf/vscode": "^1.1.0",
"@estruyf/vscode": "^1.2.1",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
"@iarna/toml": "2.2.3",
@@ -2965,7 +2996,7 @@
"@types/react": "17.0.0",
"@types/react-datepicker": "^4.8.0",
"@types/react-dom": "17.0.0",
"@types/vscode": "^1.90.0",
"@types/vscode": "^1.125.0",
"@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
+7
View File
@@ -177,6 +177,12 @@
"setting.frontMatter.global.timezone.markdownDescription": "Specify the timezone for your date formatting. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.taxonomy.datetimezone) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.taxonomy.datetimezone%22%5D)",
"setting.frontMatter.media.defaultSorting.markdownDescription": "Specify the default sorting option for the media dashboard. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.defaultsorting) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.defaultsorting%22%5D)",
"setting.frontMatter.media.supportedMimeTypes.markdownDescription": "Specify the mime types to support for the media files. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.supportedmimetypes) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.supportedmimetypes%22%5D)",
"setting.frontMatter.media.pasteBehavior.markdownDescription": "Specify what happens when you paste an image into a content file. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.pastebehavior) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.pastebehavior%22%5D)",
"setting.frontMatter.media.pasteBehavior.dashboard.description": "Open the media dashboard to pick the folder and fill in the metadata before inserting.",
"setting.frontMatter.media.pasteBehavior.auto.description": "Save the image to the page bundle or public folder and insert it straight away.",
"setting.frontMatter.media.pasteBehavior.disabled.description": "Let Visual Studio Code handle the paste.",
"setting.frontMatter.media.pasteFolder.markdownDescription": "Specify the folder to store pasted images in. Relative to the workspace, and supports the `[[workspace]]` placeholder. When left empty, the page bundle folder or the public folder is used. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.pastefolder) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.pastefolder%22%5D)",
"setting.frontMatter.media.pasteFileName.markdownDescription": "Specify the file name to use for pasted images, without the extension. Supports the `{filename}`, `{date}`, and `{time}` placeholders. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.pastefilename) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.pastefilename%22%5D)",
"setting.frontMatter.media.contentTypes.markdownDescription": "Specify the media content types you want to use in Front Matter. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.media.contenttypes) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.media.contenttypes%22%5D)",
"setting.frontMatter.media.contentTypes.items.description": "Define the media content types you want to use in Front Matter.",
"setting.frontMatter.media.contentTypes.items.properties.name.description": "Name of the media content type",
@@ -235,6 +241,7 @@
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.dataFileAdditionalFields.description": "Specify additional field names from the data record to include when storing the value. When set, the frontmatter value will be an object containing the dataFileKey field plus these additional fields.",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.editable.description": "Specify if the field is editable",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.encodeEmoji.description": "Specify if the field should encode emoji",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.translate.description": "Specify if the field value should be machine translated when creating a translation of the content. The title and description fields are translated by default, set it to `false` to exclude them.",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.dateFormat.description": "Specify the date format to use",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.required.description": "Specify if the field is required",
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.contentTypeName.description": "Specify the content type name to filter content for the contentRelationship field",
+2 -2
View File
@@ -10,7 +10,7 @@ import { commands, Uri, ViewColumn, Webview, WebviewPanel, window } from 'vscode
import { DashboardSettings, Logger, Settings as SettingsHelper, Telemetry } from '../helpers';
import { DashboardCommand } from '../dashboardWebView/DashboardCommand';
import { Extension } from '../helpers/Extension';
import { WebviewHelper } from '@estruyf/vscode';
import { WebviewHelper } from '@estruyf/vscode/dist/webview/WebviewHelper';
import { DashboardData } from '../models/DashboardData';
import {
DashboardListener,
@@ -302,7 +302,7 @@ export class Dashboard {
`default-src 'none';`,
`img-src ${`vscode-file://vscode-app`} ${
webView.cspSource
} https://api.visitorbadge.io 'self' 'unsafe-inline' https://*`,
} https://api.visitorbadge.io 'self' 'unsafe-inline' https://* data:`,
`media-src ${`vscode-file://vscode-app`} ${
webView.cspSource
} 'self' 'unsafe-inline' https://*`,
+1 -1
View File
@@ -23,7 +23,7 @@ import {
} from '../helpers';
import { ContentFolder, ContentType, PreviewSettings } from '../models';
import { Article } from '.';
import { WebviewHelper } from '@estruyf/vscode';
import { WebviewHelper } from '@estruyf/vscode/dist/webview/WebviewHelper';
import { Folders } from './Folders';
import { ParsedFrontMatter } from '../parsers';
import { getLocalizationFile } from '../utils/getLocalizationFile';
+321 -85
View File
@@ -18,8 +18,15 @@ import {
openFileInEditor,
parseWinPath
} from '../helpers';
import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants';
import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models';
import { COMMAND_NAME, SETTING_CONTENT_I18N, SETTING_TAXONOMY_FIELD_GROUPS } from '../constants';
import {
ContentFolder,
Field,
FieldGroup,
FieldType,
I18nConfig,
ContentType as IContentType
} from '../models';
import { join, parse } from 'path';
import { existsAsync, getDescriptionField, getTitleField } from '../utils';
import { Folders } from '.';
@@ -28,11 +35,38 @@ import { PagesListener } from '../listeners/dashboard';
import { LocalizationKey, localize } from '../localization';
import { Translations } from '../services/Translations';
/**
* Reference to a value which needs to be translated.
*/
interface TranslationValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
container: any;
key: string | number;
}
export class i18n {
private static processedFiles: {
[filePath: string]: { dir: string; filename: string; isPageBundle: boolean };
} = {};
/**
* The field types which hold values that should never be machine translated.
*/
private static readonly nonTranslatableFieldTypes: FieldType[] = [
'boolean',
'choice',
'contentRelationship',
'dataFile',
'datetime',
'divider',
'draft',
'file',
'heading',
'image',
'json',
'number'
];
/**
* Registers the i18n commands.
*/
@@ -133,21 +167,14 @@ export class i18n {
return false;
}
const fileInfo = await i18n.getFileInfo(filePath);
if (pageFolder.path) {
if (pageFolder.locale) {
return pageFolder.locale === pageFolder.defaultLocale;
}
let pageFolderPath = parseWinPath(pageFolder.path);
if (!pageFolderPath.endsWith('/')) {
pageFolderPath += '/';
}
return (
parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).toLowerCase()
);
// The content can be nested within the locale folder, that is why it only checks
// if the file is located in the folder instead of comparing the directories.
return typeof i18n.getRelativeFilePath(filePath, pageFolder.path) !== 'undefined';
}
return false;
@@ -166,20 +193,11 @@ export class i18n {
let pageFolder = await 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 &&
pageFolder.locale &&
parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolderPath).toLowerCase()
) {
return i18nSettings.find((i18n) => i18n.locale === pageFolder?.locale);
if (pageFolder && pageFolder.defaultLocale && pageFolder.path && pageFolder.locale) {
// The content can be nested within the locale folder (ex. `content/de/posts/2024/07/my-post/index.md`),
// that is why it only checks if the file is located in the locale folder.
if (typeof i18n.getRelativeFilePath(filePath, pageFolder.path) !== 'undefined') {
return i18nSettings.find((setting) => setting.locale === pageFolder?.locale);
}
}
@@ -188,12 +206,16 @@ export class i18n {
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;
}
// Check in which locale folder the file is located
const sourcePath = pageFolder.localeSourcePath || pageFolder.path;
const locales = i18nSettings
.filter((setting) => setting.path && pageFolder?.defaultLocale !== setting.locale)
.sort((a, b) => (b.path as string).length - (a.path as string).length);
for (const locale of locales) {
const localePath = join(sourcePath, locale.path as string);
if (typeof i18n.getRelativeFilePath(filePath, localePath) !== 'undefined') {
return locale;
}
}
@@ -227,14 +249,15 @@ export class i18n {
} = {};
let pageFolder = await Folders.getPageFolderByFilePath(filePath);
const fileInfo = await i18n.getFileInfo(filePath);
if (pageFolder && pageFolder.defaultLocale && pageFolder.localeSourcePath) {
for (const i18n of i18nSettings) {
const translation = join(pageFolder.localeSourcePath, i18n.path || '', fileInfo.filename);
const relFilePath = await i18n.getLocaleRelativeFilePath(filePath, pageFolder);
for (const setting of i18nSettings) {
const translation = join(pageFolder.localeSourcePath, setting.path || '', relFilePath);
if (await existsAsync(translation)) {
translations[i18n.locale] = {
locale: i18n,
translations[setting.locale] = {
locale: setting,
path: translation
};
}
@@ -247,11 +270,14 @@ export class i18n {
return translations;
}
for (const i18n of i18nSettings) {
const translation = join(pageFolder.path, i18n.path || '', fileInfo.filename);
const relFilePath = await i18n.getLocaleRelativeFilePath(filePath, pageFolder);
const sourcePath = pageFolder.localeSourcePath || pageFolder.path;
for (const setting of i18nSettings) {
const translation = join(sourcePath, setting.path || '', relFilePath);
if (await existsAsync(translation)) {
translations[i18n.locale] = {
locale: i18n,
translations[setting.locale] = {
locale: setting,
path: translation
};
}
@@ -346,18 +372,11 @@ export class i18n {
return;
}
// Get the directory of the file
// Get the directory of the file relative to its locale folder
const fileInfo = parse(fileUri.fsPath);
let dir = fileInfo.dir;
let pageBundleDir = '';
const contentDir = await i18n.getLocaleRelativeDir(fileUri.fsPath, pageFolder);
if (await ArticleHelper.isPageBundle(fileUri.fsPath)) {
dir = ArticleHelper.getPageFolderFromBundlePath(fileUri.fsPath);
pageBundleDir = fileUri.fsPath.replace(dir, '');
pageBundleDir = join(parse(pageBundleDir).dir);
}
const i18nDir = join(pageFolder.localeSourcePath, targetLocale.path, pageBundleDir);
const i18nDir = join(pageFolder.localeSourcePath, targetLocale.path, contentDir);
if (!(await existsAsync(i18nDir))) {
await workspace.fs.createDirectory(Uri.file(i18nDir));
@@ -379,7 +398,7 @@ export class i18n {
}
if (sourceLocale?.locale) {
article = await i18n.translate(article, sourceLocale, targetLocale);
article = await i18n.translate(article, contentType, sourceLocale, targetLocale);
}
const newFileUri = Uri.file(newFilePath);
@@ -452,12 +471,7 @@ export class i18n {
// Determine translation file paths
const fileInfo = parse(fileUri.fsPath);
let pageBundleDir = '';
if (await ArticleHelper.isPageBundle(fileUri.fsPath)) {
const dir = ArticleHelper.getPageFolderFromBundlePath(fileUri.fsPath);
pageBundleDir = fileUri.fsPath.replace(dir, '');
pageBundleDir = join(parse(pageBundleDir).dir);
}
const contentDir = await i18n.getLocaleRelativeDir(fileUri.fsPath, pageFolder);
// Gather target locales & metadata
const translations = (await i18n.getTranslations(fileUri.fsPath)) || {};
@@ -468,14 +482,9 @@ export class i18n {
.map((i18n) => {
return {
...i18n,
dir: join(pageFolder.localeSourcePath!, i18n.path!, pageBundleDir),
absolutePath: join(
pageFolder.localeSourcePath!,
i18n.path!,
pageBundleDir,
fileInfo.base
),
relativePath: join(i18n.path!, pageBundleDir, fileInfo.base)
dir: join(pageFolder.localeSourcePath!, i18n.path!, contentDir),
absolutePath: join(pageFolder.localeSourcePath!, i18n.path!, contentDir, fileInfo.base),
relativePath: join(i18n.path!, contentDir, fileInfo.base)
};
})
.sort((a, b) => (a.title || a.locale).localeCompare(b.title || b.locale));
@@ -560,7 +569,7 @@ export class i18n {
targetLocale.dir
);
if (sourceLocale?.locale) {
article = await i18n.translate(article, sourceLocale, targetLocale);
article = await i18n.translate(article, contentType, sourceLocale, targetLocale);
}
const newFileUri = Uri.file(targetLocale.absolutePath);
@@ -584,12 +593,14 @@ export class i18n {
/**
* Translates the given article from the source locale to the target locale using DeepL translation service.
* @param article - The article to be translated.
* @param contentType - The content type of the article.
* @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,
contentType: IContentType,
sourceLocale: I18nConfig,
targetLocale: I18nConfig
) {
@@ -602,28 +613,26 @@ export class i18n {
},
async () => {
try {
const titleField = getTitleField();
const descriptionField = getDescriptionField();
const title = article.data[titleField] || '';
const description = article.data[descriptionField] || '';
const content = article.content || '';
const text = [title, description, content];
const translations = await Translations.translate(
text,
sourceLocale.locale,
targetLocale.locale
);
if (!translations || translations.length < 3) {
const values = i18n.getValuesToTranslate(article, contentType);
if (values.length === 0) {
resolve(article);
return;
}
article.data[titleField] = article.data[titleField] ? translations[0] : '';
article.data[descriptionField] = article.data[descriptionField] ? translations[1] : '';
article.content = article.content ? translations[2] : '';
const translations = await Translations.translate(
values.map((value) => value.container[value.key] as string),
sourceLocale.locale,
targetLocale.locale
);
if (!translations || translations.length !== values.length) {
resolve(article);
return;
}
for (let i = 0; i < values.length; i++) {
values[i].container[values[i].key] = translations[i];
}
} catch (error) {
Notifications.error(`${(error as Error).message}`);
}
@@ -634,6 +643,158 @@ export class i18n {
});
}
/**
* Retrieves all the values of the article which need to be translated.
*
* The title and description fields are translated by default, unless the field is
* defined with `translate: false`. All other fields require `translate: true` to be
* taken into account.
*
* @param article - The parsed front matter of the article.
* @param contentType - The content type of the article.
* @returns The references to the values which need to be translated.
*/
private static getValuesToTranslate(
article: ParsedFrontMatter,
contentType: IContentType
): TranslationValue[] {
const values: TranslationValue[] = [];
// The title and description fields are translated by default
const defaultFields = [getTitleField(), getDescriptionField()];
for (const fieldName of defaultFields) {
const field = ContentType.findFieldByName(contentType.fields, fieldName);
if (field && field.translate === false) {
continue;
}
i18n.addValueToTranslate(article.data, fieldName, values);
}
i18n.getFieldValuesToTranslate(contentType.fields, article.data, values);
// The content of the article is always translated
i18n.addValueToTranslate(article, 'content', values);
return values;
}
/**
* Retrieves the values of the fields which are marked to be translated.
*
* @param fields - The fields to check.
* @param data - The data object which holds the field values.
* @param values - The references to the values which need to be translated.
*/
private static getFieldValuesToTranslate(
fields: Field[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { [key: string]: any } | undefined,
values: TranslationValue[]
): void {
if (!fields || !data) {
return;
}
for (const field of fields) {
const value = data[field.name];
if (field.type === 'fields' && field.fields) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
i18n.getFieldValuesToTranslate(field.fields, value, values);
}
continue;
}
if (field.type === 'block') {
i18n.getBlockValuesToTranslate(field, value, values);
continue;
}
if (!field.translate || i18n.nonTranslatableFieldTypes.includes(field.type)) {
continue;
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
i18n.addValueToTranslate(value, i, values);
}
} else {
i18n.addValueToTranslate(data, field.name, values);
}
}
}
/**
* Retrieves the values to translate from the items of a block field.
*
* @param field - The block field.
* @param value - The value of the block field.
* @param values - The references to the values which need to be translated.
*/
private static getBlockValuesToTranslate(
field: Field,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
values: TranslationValue[]
): void {
if (!Array.isArray(value)) {
return;
}
const fieldGroups = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
if (!fieldGroups) {
return;
}
const groupIds = Array.isArray(field.fieldGroup)
? field.fieldGroup
: field.fieldGroup
? [field.fieldGroup]
: [];
for (const item of value) {
if (!item || typeof item !== 'object') {
continue;
}
// When the block only allows a single group, the item doesn't need to define it
const groupId = item.fieldGroup || (groupIds.length === 1 ? groupIds[0] : undefined);
const group = fieldGroups.find((group) => group.id === groupId);
if (!group || !group.fields) {
continue;
}
i18n.getFieldValuesToTranslate(group.fields, item, values);
}
}
/**
* Adds the reference to the value to translate, when the value is a non-empty string
* and it hasn't been added yet.
*
* @param container - The object or array which holds the value.
* @param key - The key or index of the value.
* @param values - The references to the values which need to be translated.
*/
private static addValueToTranslate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
container: any,
key: string | number,
values: TranslationValue[]
): void {
const value = container[key];
if (typeof value !== 'string' || !value) {
return;
}
if (values.some((value) => value.container === container && value.key === key)) {
return;
}
values.push({ container, key });
}
/**
* Retrieves the filename and directory information from the given file path.
* If the file is a page bundle, the directory will be adjusted accordingly.
@@ -668,6 +829,71 @@ export class i18n {
return i18n.processedFiles[filePath];
}
/**
* Retrieves the path of the file relative to the given folder.
*
* @param filePath - The path of the file.
* @param folderPath - The path of the folder.
* @returns The relative path of the file, or `undefined` when the file is not located in the folder.
*/
private static getRelativeFilePath(filePath: string, folderPath?: string): string | undefined {
if (!filePath || !folderPath) {
return;
}
let parsedFolderPath = parseWinPath(folderPath);
if (!parsedFolderPath.endsWith('/')) {
parsedFolderPath += '/';
}
const parsedFilePath = parseWinPath(filePath);
if (!parsedFilePath.toLowerCase().startsWith(parsedFolderPath.toLowerCase())) {
return;
}
return parsedFilePath.substring(parsedFolderPath.length);
}
/**
* Retrieves the path of the file relative to its locale folder.
*
* The content can be nested within the locale folder, the whole folder structure is kept
* (ex. `posts/2024/07/my-post/index.md`) so that translations use the same structure.
*
* @param filePath - The path of the file.
* @param pageFolder - The content folder of the file.
* @returns The relative path of the file.
*/
private static async getLocaleRelativeFilePath(
filePath: string,
pageFolder: ContentFolder
): Promise<string> {
const relFilePath = i18n.getRelativeFilePath(filePath, pageFolder.path);
if (typeof relFilePath !== 'undefined') {
return relFilePath;
}
const fileInfo = await i18n.getFileInfo(filePath);
return fileInfo.filename;
}
/**
* Retrieves the directory of the file relative to its locale folder.
*
* @param filePath - The path of the file.
* @param pageFolder - The content folder of the file.
* @returns The relative directory of the file, or an empty string when the file is located in the root of the locale folder.
*/
private static async getLocaleRelativeDir(
filePath: string,
pageFolder: ContentFolder
): Promise<string> {
const relFilePath = await i18n.getLocaleRelativeFilePath(filePath, pageFolder);
const relDir = parse(relFilePath).dir;
return relDir ? join(relDir) : '';
}
/**
* Retrieves the page folder for a given file path.
*
@@ -682,6 +908,16 @@ export class i18n {
return;
}
// Check in which locale folder the file is located, the deepest folder wins
const sortedFolders = [...localeFolders].sort(
(a, b) => (b.path || '').length - (a.path || '').length
);
for (const folder of sortedFolders) {
if (typeof i18n.getRelativeFilePath(filePath, folder.path) !== 'undefined') {
return folder;
}
}
const fileInfo = await i18n.getFileInfo(filePath);
for (const folder of localeFolders) {
+4
View File
@@ -81,6 +81,10 @@ export const SETTING_CONTENT_HIDE_FRONTMATTER_MESSAGE = 'content.hideFmMessage';
export const SETTING_MEDIA_SUPPORTED_MIMETYPES = 'media.supportedMimeTypes';
export const SETTING_MEDIA_CONTENTTYPES = 'media.contentTypes';
export const SETTING_MEDIA_PASTE_BEHAVIOR = 'media.pasteBehavior';
export const SETTING_MEDIA_PASTE_FOLDER = 'media.pasteFolder';
export const SETTING_MEDIA_PASTE_FILENAME = 'media.pasteFileName';
export const SETTING_DASHBOARD_OPENONSTART = 'dashboard.openOnStart';
export const SETTING_DASHBOARD_CONTENT_TAGS = 'dashboard.content.cardTags';
export const SETTING_DASHBOARD_CONTENT_PAGINATION = 'dashboard.content.pagination';
+1
View File
@@ -40,6 +40,7 @@ export enum DashboardMessage {
copyToClipboard = 'copyToClipboard',
refreshMedia = 'refreshMedia',
uploadMedia = 'uploadMedia',
uploadPastedMedia = 'uploadPastedMedia',
deleteMedia = 'deleteMedia',
revealMedia = 'revealMedia',
insertMedia = 'insertMedia',
@@ -15,6 +15,14 @@ export interface IDetailsFormProps {
isImageFile: boolean;
isVideoFile: boolean;
onDismiss: () => void;
/**
* Label for the submit button, defaults to the common save label
*/
submitLabel?: string;
/**
* Called with the submitted metadata once it has been stored
*/
onSubmitted?: (metadata: { [fieldName: string]: string }) => void;
}
export const DetailsForm: React.FunctionComponent<IDetailsFormProps> = ({
@@ -22,6 +30,8 @@ export const DetailsForm: React.FunctionComponent<IDetailsFormProps> = ({
isImageFile,
isVideoFile,
onDismiss,
submitLabel,
onSubmitted,
}: React.PropsWithChildren<IDetailsFormProps>) => {
const settings = useRecoilValue(SettingsAtom);
const selectedFolder = useRecoilValue(SelectedMediaFolderSelector);
@@ -71,8 +81,14 @@ export const DetailsForm: React.FunctionComponent<IDetailsFormProps> = ({
metadata,
});
// When a submit handler is provided, it takes over from here
if (onSubmitted) {
onSubmitted(metadata);
return;
}
onDismiss();
}, [media, filename, metadata, selectedFolder, page, onDismiss]);
}, [media, filename, metadata, selectedFolder, page, onDismiss, onSubmitted]);
const formFields = useMemo(() => {
return fields.map((field) => {
@@ -212,7 +228,7 @@ export const DetailsForm: React.FunctionComponent<IDetailsFormProps> = ({
onClick={onSubmitMetadata}
disabled={!filename}
>
{l10n.t(LocalizationKey.commonSave)}
{submitLabel || l10n.t(LocalizationKey.commonSave)}
</button>
<button
type="button"
@@ -32,6 +32,7 @@ import { MediaInfo } from '../../../models';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { MediaItemPanel } from './MediaItemPanel';
import { MediaPastePanel } from './MediaPastePanel';
import { FilesProvider } from '../../providers/FilesProvider';
import { SortOption } from '../../constants/SortOption';
import { Pagination } from '../Header/Pagination';
@@ -183,6 +184,8 @@ export const Media: React.FunctionComponent<IMediaProps> = () => {
<FilesProvider files={allMedia}>
<PageLayout>
<div className="w-full h-full pb-6" {...getRootProps()}>
<MediaPastePanel />
{viewData?.data?.filePath && (
<div className={`text-lg text-center mb-6`}>
<p>{l10n.t(LocalizationKey.dashboardMediaMediaDescription)}</p>
@@ -0,0 +1,312 @@
import * as React from 'react';
import * as l10n from '@vscode/l10n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Messenger, messageHandler } from '@estruyf/vscode/dist/client';
import { useRecoilValue } from 'recoil';
import { CodeBracketIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { basename } from 'path';
import { DashboardMessage } from '../../DashboardMessage';
import { LocalizationKey } from '../../../localization';
import { MediaInfo, Snippet } from '../../../models';
import { SelectedMediaFolderAtom, SettingsSelector, ViewDataSelector } from '../../state';
import useMediaFolder from '../../hooks/useMediaFolder';
import { getRelPath } from '../../utils';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { SnippetParser } from '../../../helpers/SnippetParser';
import { DetailsForm } from './DetailsForm';
import { MediaSnippetForm } from './MediaSnippetForm';
import { InfoDialog } from '../Modals/InfoDialog';
export interface IMediaPastePanelProps { }
type PasteStep = 'folder' | 'metadata' | 'snippet' | 'snippetForm';
/**
* Panel which guides the pasted media through picking a folder, adding the
* metadata, and inserting it into the content file it was pasted in.
*/
export const MediaPastePanel: React.FunctionComponent<IMediaPastePanelProps> = () => {
const viewData = useRecoilValue(ViewDataSelector);
const settings = useRecoilValue(SettingsSelector);
const selectedFolder = useRecoilValue(SelectedMediaFolderAtom);
const { updateFolder } = useMediaFolder();
const [dismissed, setDismissed] = useState(false);
const [step, setStep] = useState<PasteStep>('folder');
const [storing, setStoring] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [storedMedia, setStoredMedia] = useState<MediaInfo | undefined>(undefined);
const [metadata, setMetadata] = useState<{ [fieldName: string]: string }>({});
const [snippet, setSnippet] = useState<Snippet | undefined>(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [mediaData, setMediaData] = useState<any | undefined>(undefined);
const pendingUpload = useMemo(() => viewData?.data?.pendingUpload, [viewData]);
const suggestedFolder = useMemo(() => viewData?.data?.suggestedFolder, [viewData]);
const relFolder = useMemo(() => {
if (!selectedFolder) {
return undefined;
}
const wsFolder = settings?.wsFolder;
const folder = wsFolder ? selectedFolder.replace(wsFolder, '') : selectedFolder;
return folder || '/';
}, [selectedFolder, settings?.wsFolder]);
const mediaSnippets = useMemo(() => {
if (!settings?.snippets) {
return [];
}
const keys = Object.keys(settings.snippets);
return keys
.filter((key) => (settings.snippets || {})[key].isMediaSnippet)
.map((key) => ({ title: key, ...(settings.snippets || {})[key] }));
}, [settings]);
const relPath = useMemo(() => {
if (!storedMedia) {
return undefined;
}
return getRelPath(storedMedia.fsPath, settings?.staticFolder, settings?.wsFolder);
}, [storedMedia, settings?.staticFolder, settings?.wsFolder]);
// Every paste starts the flow over again
useEffect(() => {
setDismissed(false);
setStep('folder');
setStoredMedia(undefined);
setMetadata({});
setSnippet(undefined);
setMediaData(undefined);
setError(undefined);
}, [pendingUpload?.contents]);
// Start the user off in the page bundle or public folder
useEffect(() => {
if (pendingUpload && suggestedFolder && suggestedFolder !== selectedFolder) {
updateFolder(suggestedFolder);
}
}, [pendingUpload, suggestedFolder]);
const onStore = useCallback(async () => {
if (!pendingUpload || !selectedFolder) {
return;
}
setStoring(true);
setError(undefined);
try {
const media = await messageHandler.request<MediaInfo>(DashboardMessage.uploadPastedMedia, {
fileName: pendingUpload.fileName,
contents: pendingUpload.contents,
folder: selectedFolder
});
setStoredMedia(media);
setStep('metadata');
} catch {
setError(l10n.t(LocalizationKey.dashboardMediaPasteError));
} finally {
setStoring(false);
}
}, [pendingUpload, selectedFolder]);
const insertMedia = useCallback(
(fields: { [fieldName: string]: string }) => {
Messenger.send(DashboardMessage.insertMedia, {
relPath: parseWinPath(relPath) || '',
file: viewData?.data?.filePath,
fieldName: viewData?.data?.fieldName,
position: viewData?.data?.position || null,
alt: fields?.alt || '',
caption: fields?.caption || '',
title: fields?.title || ''
});
setDismissed(true);
},
[relPath, viewData]
);
const insertSnippet = useCallback(
(output: string) => {
Messenger.send(DashboardMessage.insertMedia, {
relPath: parseWinPath(relPath) || '',
file: viewData?.data?.filePath,
fieldName: viewData?.data?.fieldName,
position: viewData?.data?.position || null,
snippet: output
});
setDismissed(true);
},
[relPath, viewData]
);
const onMetadataSubmitted = useCallback(
(fields: { [fieldName: string]: string }) => {
setMetadata(fields);
// Without media snippets there is nothing to pick from
if (mediaSnippets.length === 0) {
insertMedia(fields);
return;
}
setStep('snippet');
},
[mediaSnippets, insertMedia]
);
const processSnippet = useCallback(
(mediaSnippet: Snippet) => {
const fieldData = {
mediaUrl: (parseWinPath(relPath) || '').replace(/ /g, '%20'),
filename: basename(relPath || ''),
mediaWidth: storedMedia?.dimensions?.width?.toString() || '',
mediaHeight: storedMedia?.dimensions?.height?.toString() || '',
...metadata
};
if (!mediaSnippet.fields || mediaSnippet.fields.length === 0) {
insertSnippet(
SnippetParser.render(
mediaSnippet.body,
fieldData,
mediaSnippet?.openingTags,
mediaSnippet?.closingTags
)
);
return;
}
setSnippet(mediaSnippet);
setMediaData(fieldData);
setStep('snippetForm');
},
[relPath, storedMedia, metadata, insertSnippet]
);
if (!pendingUpload || dismissed) {
return null;
}
if (step === 'snippetForm' && storedMedia && snippet && mediaData) {
return (
<MediaSnippetForm
media={storedMedia}
mediaData={mediaData}
snippet={snippet}
onDismiss={() => setStep('snippet')}
onInsert={insertSnippet}
/>
);
}
if (step === 'snippet') {
return (
<InfoDialog
icon={<CodeBracketIcon className="h-6 w-6" aria-hidden="true" />}
title={l10n.t(LocalizationKey.dashboardMediaPasteSnippetTitle)}
description={l10n.t(LocalizationKey.dashboardMediaPasteSnippetDescription)}
dismiss={() => setDismissed(true)}
>
<ul className="flex flex-wrap justify-center">
<li className="inline-flex items-center pb-2 mr-2">
<button
className={`w-full inline-flex justify-center border border-transparent shadow-sm px-4 py-2 text-base font-medium focus:outline-none sm:w-auto sm:text-sm bg-[var(--vscode-button-secondaryBackground)] text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]`}
onClick={() => insertMedia(metadata)}
>
{l10n.t(LocalizationKey.dashboardMediaPasteSnippetNone)}
</button>
</li>
{mediaSnippets.map((mediaSnippet, idx) => (
<li key={idx} className="inline-flex items-center pb-2 mr-2">
<button
className={`w-full inline-flex justify-center border border-transparent shadow-sm px-4 py-2 text-base font-medium focus:outline-none sm:w-auto sm:text-sm disabled:opacity-30 bg-[var(--frontmatter-button-background)] text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]`}
onClick={() => processSnippet(mediaSnippet)}
>
{mediaSnippet.title}
</button>
</li>
))}
</ul>
</InfoDialog>
);
}
if (step === 'metadata' && storedMedia) {
return (
<div className={`fixed inset-0 z-50 flex items-center justify-center bg-[var(--vscode-editor-background)] bg-opacity-75`}>
<div className={`w-full max-w-md max-h-full overflow-y-auto rounded border p-6 shadow-xl bg-[var(--vscode-sideBar-background)] border-[var(--frontmatter-border)]`}>
<DetailsForm
media={storedMedia}
isImageFile={true}
isVideoFile={false}
submitLabel={
mediaSnippets.length > 0
? l10n.t(LocalizationKey.commonSave)
: l10n.t(LocalizationKey.dashboardMediaPasteSaveAndInsert)
}
onSubmitted={onMetadataSubmitted}
onDismiss={() => setDismissed(true)}
/>
</div>
</div>
);
}
return (
<div className={`mb-6 flex items-center gap-4 rounded border p-4 border-[var(--frontmatter-border)] bg-[var(--vscode-sideBar-background)]`}>
<img
src={pendingUpload.contents}
alt={pendingUpload.fileName}
className={`h-20 w-20 object-contain border border-[var(--frontmatter-border)]`}
/>
<div className="flex-1 min-w-0">
<h2 className={`text-base font-medium text-[var(--frontmatter-text)]`}>
{l10n.t(LocalizationKey.dashboardMediaPasteTitle)}
</h2>
<p className={`text-sm text-[var(--vscode-editor-foreground)] opacity-80`}>
{l10n.t(LocalizationKey.dashboardMediaPasteDescription)}
</p>
<p className={`mt-1 text-sm truncate text-[var(--vscode-editor-foreground)]`}>
{relFolder
? l10n.t(LocalizationKey.dashboardMediaPasteStoreIn, relFolder)
: l10n.t(LocalizationKey.dashboardMediaPasteNoFolder)}
</p>
{error && (
<p className={`mt-1 text-sm text-[var(--vscode-statusBarItem-errorBackground)]`}>{error}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={`inline-flex justify-center rounded px-4 py-2 text-sm font-medium disabled:opacity-30 bg-[var(--frontmatter-button-background)] hover:bg-[var(--vscode-button-hoverBackground)] text-[var(--vscode-button-foreground)]`}
onClick={onStore}
disabled={!selectedFolder || storing}
>
{storing
? l10n.t(LocalizationKey.dashboardMediaPasteStoring)
: l10n.t(LocalizationKey.dashboardMediaPasteStore)}
</button>
<button
type="button"
className={`focus:outline-none text-[var(--vscode-titleBar-inactiveForeground)] hover:text-[var(--vscode-titleBar-activeForeground)]`}
onClick={() => setDismissed(true)}
>
<span className="sr-only">{l10n.t(LocalizationKey.commonCancel)}</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
);
};
+4
View File
@@ -2,6 +2,7 @@ import { GitListener } from './listeners/general/GitListener';
import * as vscode from 'vscode';
import { COMMAND_NAME, CONTEXT, EXTENSION_NAME } from './constants';
import { MarkdownFoldingProvider } from './providers/MarkdownFoldingProvider';
import { MediaPasteProvider } from './providers/MediaPasteProvider';
import { PanelProvider } from './panelWebView/PanelProvider';
import {
DashboardSettings,
@@ -116,6 +117,9 @@ export async function activate(context: vscode.ExtensionContext) {
// Folding the front matter of markdown files
MarkdownFoldingProvider.register();
// Pasting media into the content files
MediaPasteProvider.register(subscriptions);
// Register the taxonomy commands
Taxonomy.registerCommands(subscriptions);
+1 -1
View File
@@ -41,7 +41,7 @@ import { format, parse } from 'date-fns';
import { Notifications } from './Notifications';
import { Article } from '../commands';
import { dirname, join, parse as parseFile } from 'path';
import { EditorHelper } from '@estruyf/vscode';
import { EditorHelper } from '@estruyf/vscode/dist/editor/EditorHelper';
import sanitize from '../helpers/Sanitize';
import { ContentFolder, Field, ContentType as IContentType } from '../models';
import { DiagnosticSeverity, Position, window, Range } from 'vscode';
+4
View File
@@ -26,6 +26,7 @@ import {
import { ContentFolder, TaxonomyType } from '../models';
import { Notifications } from './Notifications';
import { Settings } from './SettingsHelper';
import { reformatJsonDbs } from './JsonDbHelper';
import { TaxonomyHelper } from './TaxonomyHelper';
import { Cache } from '../commands/Cache';
import * as l10n from '@vscode/l10n';
@@ -269,6 +270,9 @@ export class Extension {
await Settings.safeUpdate(SETTING_CONTENT_PAGE_FOLDERS, folders, true);
}
// Rewrite the databases which are still stored on a single line
await reformatJsonDbs();
// The tags and categories settings need to be moved to the database
const tags = Settings.get<string[]>(SETTING_TAXONOMY_TAGS) || [];
const categories = Settings.get<string[]>(SETTING_TAXONOMY_CATEGORIES) || [];
+187
View File
@@ -0,0 +1,187 @@
import { ConfigWithAdapter, FileAdapter, IAdapter, JsonDB } from 'node-json-db';
import { ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
import * as l10n from '@vscode/l10n';
import { LocalStore } from '../constants';
import { LocalizationKey } from '../localization';
import { join } from 'path';
import { Folders } from '../commands/Folders';
import { existsAsync, readFileAsync } from '../utils';
import { parseWinPath } from './parseWinPath';
import { Notifications } from './Notifications';
import { Logger } from './Logger';
const CONFLICT_MARKER = /^<{7}(?: |\t|$)/m;
/**
* Adapter to store the Front Matter databases in a Git-friendly format.
*
* The databases live in the `.frontmatter/database` folder and are versioned by most
* teams. Storing them on a single minified line leaves Git nothing to merge line by
* line, so the data gets indented and its object keys sorted to keep the diffs stable.
*/
class JsonDbAdapter implements IAdapter<any> {
private readonly dateRegex = new RegExp('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}', 'm');
constructor(private readonly adapter: IAdapter<string>, private readonly dbPath: string) {}
public async readAsync(): Promise<any> {
const data = await this.adapter.readAsync();
if (data === null) {
await this.writeAsync({});
return {};
}
const errors: ParseError[] = [];
const contents = parseJsonc(data, errors, { allowTrailingComma: true });
if (errors.length > 0) {
// Let the error bubble up, as the database stays unloaded, no write can
// overwrite the file before the user got the chance to fix it
const parseError = JsonDbAdapter.getParseError(data, errors[0]);
this.notifyCorrupted(data, parseError);
throw new Error(`Failed to parse "${this.dbPath}": ${parseError}`);
}
return this.revive(contents);
}
public writeAsync(data: any): Promise<void> {
// The trailing newline keeps the file from showing up as changed in every diff
return this.adapter.writeAsync(`${JSON.stringify(data, JsonDbAdapter.replacer, 2)}\n`);
}
/**
* Sort the object keys, so that the same data always writes the same file
*/
private static replacer(_: string, value: any) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return value;
}
return Object.keys(value)
.sort()
.reduce((sorted: Record<string, any>, key: string) => {
sorted[key] = value[key];
return sorted;
}, {});
}
/**
* Turn the date strings back into dates, as the JSONC parser takes no reviver
*/
private revive(value: any): any {
if (typeof value === 'string') {
return this.dateRegex.exec(value) !== null ? new Date(value) : value;
}
if (Array.isArray(value)) {
return value.map((item) => this.revive(item));
}
if (value !== null && typeof value === 'object') {
for (const key of Object.keys(value)) {
value[key] = this.revive(value[key]);
}
}
return value;
}
/**
* Point the user to the place where the file breaks, a line and column are
* easier to find back than the character offset
*/
private static getParseError(contents: string, error: ParseError) {
const upToError = contents.substring(0, error.offset);
const line = upToError.split('\n').length;
const column = error.offset - upToError.lastIndexOf('\n');
return `${printParseErrorCode(error.error)} at line ${line}, column ${column}`;
}
/**
* Tell the user which file is broken and why, instead of failing silently
*/
private notifyCorrupted(contents: string, error: string) {
const localPath = JsonDbAdapter.getLocalPath(this.dbPath);
const message = CONFLICT_MARKER.test(contents)
? l10n.t(LocalizationKey.helpersJsonDbHelperConflictError, localPath)
: l10n.t(LocalizationKey.helpersJsonDbHelperParseError, localPath, error);
Notifications.errorShowOnce(message);
}
/**
* Show the path relative to the project, as that is how the user knows the file
*/
private static getLocalPath(dbPath: string) {
const path = parseWinPath(dbPath);
const idx = path.indexOf(`/${LocalStore.rootFolder}/`);
return idx > -1 ? path.substring(idx + 1) : path;
}
}
/**
* Create a database for one of the files in the `.frontmatter/database` folder
* @param dbPath
* @returns
*/
export const createJsonDb = (dbPath: string): JsonDB => {
const adapter = new JsonDbAdapter(new FileAdapter(dbPath, false), dbPath);
return new JsonDB(new ConfigWithAdapter(adapter, true, '/'));
};
/**
* Rewrite the databases which are still stored on a single line
*
* Without this, the files only get reformatted once the extension writes to them,
* which spreads the change out over whenever someone happens to add a tag or edit
* the metadata of a media file
*/
export const reformatJsonDbs = async (): Promise<void> => {
const wsFolder = Folders.getWorkspaceFolder();
if (!wsFolder) {
return;
}
const dbFolder = join(
parseWinPath(wsFolder.fsPath),
LocalStore.rootFolder,
LocalStore.databaseFolder
);
const dbFiles = [
LocalStore.taxonomyDatabaseFile,
LocalStore.mediaDatabaseFile,
LocalStore.pinnedItemsDatabaseFile
];
for (const dbFile of dbFiles) {
const dbPath = join(dbFolder, dbFile);
// Only touch the files which are already there, creating the missing ones would
// add files to the project which the user never used
if (!(await existsAsync(dbPath))) {
continue;
}
try {
const contents = await readFileAsync(dbPath, 'utf8');
if (contents.startsWith(`{\n`)) {
continue;
}
const db = createJsonDb(dbPath);
// Loading first is crucial, saving an unloaded database writes an empty object
await db.load();
await db.save();
Logger.info(`Reformatted "${dbFile}" to be able to merge it line by line`);
} catch (err) {
// The read already told the user which file is broken and why
Logger.error(`Failed to reformat "${dbFile}": ${(err as Error).message}`);
}
}
};
+31 -1
View File
@@ -23,7 +23,7 @@ import { basename, join, parse, dirname, relative } from 'path';
import { statSync } from 'fs';
import { Uri, workspace, window, Position } from 'vscode';
import imageSize from 'image-size';
import { EditorHelper } from '@estruyf/vscode';
import { EditorHelper } from '@estruyf/vscode/dist/editor/EditorHelper';
import { SortOption } from '../dashboardWebView/constants/SortOption';
import { DataListener, MediaListener } from '../listeners/panel';
import { MediaListener as DashboardMediaListener } from '../listeners/dashboard';
@@ -352,6 +352,36 @@ export class MediaHelpers {
return false;
}
/**
* Write raw media contents to disk, making sure the file name is unique
* @param fileName File name including its extension
* @param contents The raw file contents
* @param absFolderPath The absolute path of the folder to store the file in
* @returns The absolute path of the stored file
*/
public static async saveMediaBuffer(
fileName: string,
contents: Uint8Array,
absFolderPath: string
): Promise<string | undefined> {
if (!(await existsAsync(absFolderPath))) {
await workspace.fs.createDirectory(Uri.file(absFolderPath));
}
const { name, ext } = parse(fileName);
let filePath = join(absFolderPath, fileName);
let idx = 1;
while (await existsAsync(filePath)) {
filePath = join(absFolderPath, `${name}-${idx}${ext}`);
idx++;
}
await writeFileAsync(filePath, contents);
return filePath;
}
/**
* Delete the selected file
* @param data
+3 -14
View File
@@ -1,6 +1,7 @@
import { MediaHelpers } from './MediaHelpers';
import { Disposable, workspace } from 'vscode';
import { Config, JsonDB } from 'node-json-db';
import { JsonDB } from 'node-json-db';
import { createJsonDb } from './JsonDbHelper';
import { basename, dirname, join, parse } from 'path';
import { Folders, WORKSPACE_PLACEHOLDER } from '../commands/Folders';
import { Notifications } from './Notifications';
@@ -56,19 +57,7 @@ export class MediaLibrary {
}
}
this.db = new JsonDB(
new Config(
join(
parseWinPath(wsFolder?.fsPath || ''),
LocalStore.rootFolder,
LocalStore.databaseFolder,
LocalStore.mediaDatabaseFile
),
true,
false,
'/'
)
);
this.db = createJsonDb(dbPath);
if (this.renameFilesListener) {
this.renameFilesListener.dispose();
+3 -2
View File
@@ -17,7 +17,8 @@ import { Notifications } from './Notifications';
import { ArticleHelper } from './ArticleHelper';
import { ContentType } from './ContentType';
import { readFileAsync, writeFileAsync } from '../utils';
import { Config, JsonDB } from 'node-json-db';
import { JsonDB } from 'node-json-db';
import { createJsonDb } from './JsonDbHelper';
import { Folders } from '../commands';
import { join } from 'path';
import { SettingsListener as PanelSettingsListener } from '../listeners/panel';
@@ -47,7 +48,7 @@ export class TaxonomyHelper {
);
const dbPath = join(dbFolder, LocalStore.taxonomyDatabaseFile);
TaxonomyHelper.db = new JsonDB(new Config(dbPath, true, false, '/'));
TaxonomyHelper.db = createJsonDb(dbPath);
}
/**
+1
View File
@@ -10,6 +10,7 @@ export * from './FilesHelper';
export * from './FrameworkDetector';
export * from './GroupBy';
export * from './ImageHelper';
export * from './JsonDbHelper';
export * from './Logger';
export * from './MediaHelpers';
export * from './MediaLibrary';
+65 -2
View File
@@ -7,8 +7,12 @@ import { commands, env, ProgressLocation, Uri, window, workspace } from 'vscode'
import { COMMAND_NAME } from '../../constants';
import * as os from 'os';
import { Folders } from '../../commands';
import { PostMessageData, UnmappedMedia } from '../../models';
import { FilesHelper, MediaLibrary } from '../../helpers';
import { MediaInfo, PostMessageData, UnmappedMedia } from '../../models';
import { decodeBase64, FilesHelper, MediaLibrary } from '../../helpers';
import { Dashboard } from '../../commands/Dashboard';
import { lookup } from 'mime-types';
import imageSize from 'image-size';
import { ISizeCalculationResult } from 'image-size/dist/types/interface';
import { existsAsync, flattenObjectKeys } from '../../utils';
import { join, parse } from 'path';
import { LocalizationKey, localize } from '../../localization';
@@ -32,6 +36,9 @@ export class MediaListener extends BaseListener {
case DashboardMessage.uploadMedia:
this.store(msg?.payload);
break;
case DashboardMessage.uploadPastedMedia:
await this.storePastedMedia(msg);
break;
case DashboardMessage.deleteMedia:
this.delete(msg?.payload);
break;
@@ -262,6 +269,62 @@ export class MediaListener extends BaseListener {
}
}
/**
* Store a pasted media file in the selected folder and report the stored file
* back to the dashboard so it can collect the metadata and insert it.
* @param msg
*/
private static async storePastedMedia({ command, payload, requestId }: PostMessageData) {
if (!command || !requestId) {
return;
}
const { fileName, contents, folder } = payload ?? {};
if (!fileName || !contents || !folder) {
this.sendError(command as DashboardCommand, requestId, 'Missing paste information');
return;
}
const data = decodeBase64(contents);
if (!data) {
this.sendError(command as DashboardCommand, requestId, 'Invalid media contents');
return;
}
let fsPath: string | undefined;
try {
fsPath = await MediaHelpers.saveMediaBuffer(fileName, data.data, folder);
} catch (e) {
fsPath = undefined;
}
if (!fsPath) {
this.sendError(command as DashboardCommand, requestId, 'Could not store the media');
return;
}
const mimeType = lookup(fsPath) || undefined;
let dimensions: ISizeCalculationResult | undefined;
try {
dimensions = mimeType?.startsWith('image/') ? imageSize(fsPath) : undefined;
} catch {
dimensions = undefined;
}
MediaHelpers.resetMedia();
this.sendRequest(command as DashboardCommand, requestId, {
filename: parse(fsPath).base,
fsPath,
vsPath: Dashboard.getWebview()?.asWebviewUri(Uri.file(fsPath))?.toString(),
dimensions,
mimeType,
metadata: {}
} as MediaInfo);
this.sendMediaFiles(0, folder);
}
/**
* Delete a media file
* @param data
+1 -1
View File
@@ -1,4 +1,4 @@
import { EditorHelper } from '@estruyf/vscode';
import { EditorHelper } from '@estruyf/vscode/dist/editor/EditorHelper';
import { window, Range, Position } from 'vscode';
import { Dashboard } from '../../commands/Dashboard';
import { SETTING_CONTENT_SNIPPETS, SETTING_DATE_FORMAT } from '../../constants';
+33 -5
View File
@@ -45,7 +45,7 @@ import {
} from '../../models';
import { encodeEmoji, fieldWhenClause, getTitleField } from '../../utils';
import { PanelProvider } from '../../panelWebView/PanelProvider';
import { MessageHandlerData } from '@estruyf/vscode';
import type { MessageHandlerData } from '@estruyf/vscode';
import { SponsorAi } from '../../services/SponsorAI';
import { Terminal } from '../../services';
import * as l10n from '@vscode/l10n';
@@ -580,15 +580,43 @@ export class DataListener extends BaseListener {
const threshold = Settings.get<number>(SETTING_CONTENT_HEALTH_FRESHNESS_THRESHOLD) ?? 180;
let freshnessWarning: { daysSince: number; threshold: number } | null = null;
if (threshold > 0) {
const dateValue = (metadata?.date || metadata?.publishDate) as string | undefined;
if (dateValue) {
const dateFormat = Settings.get<string>(SETTING_DATE_FORMAT);
const parsed = DateHelper.tryParse(dateValue, dateFormat || undefined);
const article = {
content: articleDetails.content || '',
data: metadata,
path: filePath
} as ParsedFrontMatter;
const contentType = await ArticleHelper.getContentType(article);
const modifiedDateField = contentType.fields.find((field) => field.isModifiedDate);
const publishDateField = contentType.fields.find((field) => field.isPublishDate);
const defaultDateFormat = Settings.get<string>(SETTING_DATE_FORMAT) || undefined;
const dateCandidates: { value: unknown; dateFormat?: string }[] = [
{
value: modifiedDateField?.name ? metadata?.[modifiedDateField.name] : undefined,
dateFormat: modifiedDateField?.dateFormat || defaultDateFormat
},
{
value: publishDateField?.name ? metadata?.[publishDateField.name] : undefined,
dateFormat: publishDateField?.dateFormat || defaultDateFormat
},
{
value: metadata?.date,
dateFormat: defaultDateFormat
},
{
value: metadata?.publishDate,
dateFormat: defaultDateFormat
}
];
for (const candidate of dateCandidates) {
const parsed = DateHelper.tryParse(candidate.value, candidate.dateFormat);
if (parsed && DateHelper.isValid(parsed)) {
const daysSince = Math.floor((Date.now() - parsed.getTime()) / (1000 * 60 * 60 * 24));
if (daysSince > threshold) {
freshnessWarning = { daysSince, threshold };
}
break;
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { SettingsListener as PanelSettingsListener } from '.';
import { SettingsListener as DashboardSettingsListener } from '../dashboard';
import { SponsorAi } from '../../services/SponsorAI';
import { PanelProvider } from '../../panelWebView/PanelProvider';
import { MessageHandlerData } from '@estruyf/vscode';
import type { MessageHandlerData } from '@estruyf/vscode';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
import { Copilot } from '../../services/Copilot';
+61 -1
View File
@@ -771,6 +771,50 @@ export enum LocalizationKey {
* Close panel
*/
dashboardMediaPanelClose = 'dashboard.media.panel.close',
/**
* Paste media
*/
dashboardMediaPasteTitle = 'dashboard.media.paste.title',
/**
* Browse to the folder you want to store the image in.
*/
dashboardMediaPasteDescription = 'dashboard.media.paste.description',
/**
* Store in: {0}
*/
dashboardMediaPasteStoreIn = 'dashboard.media.paste.storeIn',
/**
* Select a folder to store the image in.
*/
dashboardMediaPasteNoFolder = 'dashboard.media.paste.noFolder',
/**
* Store image
*/
dashboardMediaPasteStore = 'dashboard.media.paste.store',
/**
* Storing the image...
*/
dashboardMediaPasteStoring = 'dashboard.media.paste.storing',
/**
* The image could not be stored. Please try again.
*/
dashboardMediaPasteError = 'dashboard.media.paste.error',
/**
* Save and insert
*/
dashboardMediaPasteSaveAndInsert = 'dashboard.media.paste.saveAndInsert',
/**
* Insert the image
*/
dashboardMediaPasteSnippetTitle = 'dashboard.media.paste.snippetTitle',
/**
* Select how you want to insert the image into your content.
*/
dashboardMediaPasteSnippetDescription = 'dashboard.media.paste.snippetDescription',
/**
* Without snippet
*/
dashboardMediaPasteSnippetNone = 'dashboard.media.paste.snippetNone',
/**
* Update metadata
*/
@@ -2492,6 +2536,14 @@ export enum LocalizationKey {
* Sorry, something went wrong updating "{0}" to "{1}".
*/
helpersMediaLibraryRemoveError = 'helpers.mediaLibrary.remove.error',
/**
* The "{0}" file still contains Git conflict markers. Resolve the conflict in the file to make Front Matter read it again.
*/
helpersJsonDbHelperConflictError = 'helpers.jsonDbHelper.conflict.error',
/**
* The "{0}" file could not be read, as it does not contain valid JSON: {1}
*/
helpersJsonDbHelperParseError = 'helpers.jsonDbHelper.parse.error',
/**
* Couldn't open the file.
*/
@@ -2851,5 +2903,13 @@ export enum LocalizationKey {
/**
* Starting local server
*/
servicesTerminalOpenLocalServerTerminalTerminalOptionMessage = 'services.terminal.openLocalServerTerminal.terminalOption.message'
servicesTerminalOpenLocalServerTerminalTerminalOptionMessage = 'services.terminal.openLocalServerTerminal.terminalOption.message',
/**
* Insert media with Front Matter
*/
providersMediaPasteTitle = 'providers.mediaPaste.title',
/**
* Front Matter: the pasted media could not be stored in "{0}".
*/
providersMediaPasteSaveFailed = 'providers.mediaPaste.saveFailed'
}
+12
View File
@@ -24,11 +24,23 @@ export interface ViewData {
multiple?: string[];
value?: string;
// Media paste fields
suggestedFolder?: string;
pendingUpload?: PendingUpload;
// File fields
type: 'file' | 'media';
fileExtensions?: string[];
}
export interface PendingUpload {
fileName: string;
/**
* The base64 encoded contents of the file
*/
contents: string;
}
export interface SnippetRange {
start: Position;
end: Position;
+1
View File
@@ -119,6 +119,7 @@ export interface Field {
editable?: boolean;
required?: boolean;
encodeEmoji?: boolean;
translate?: boolean;
// Date fields
isPublishDate?: boolean;
+1 -1
View File
@@ -24,7 +24,7 @@ import {
import { ArticleHelper, Logger, Settings } from '../helpers';
import { Command } from '../panelWebView/Command';
import { TagType } from '../panelWebView/TagType';
import { WebviewHelper } from '@estruyf/vscode';
import { WebviewHelper } from '@estruyf/vscode/dist/webview/WebviewHelper';
import { Extension } from '../helpers/Extension';
import { Telemetry } from '../helpers/Telemetry';
import { GitListener, ModeListener } from '../listeners/general';
+302
View File
@@ -0,0 +1,302 @@
import {
CancellationToken,
DataTransfer,
DataTransferFile,
DocumentDropOrPasteEditKind,
DocumentPasteEdit,
DocumentPasteEditContext,
DocumentPasteEditProvider,
DocumentPasteProviderMetadata,
DocumentSelector,
Disposable,
Range,
TextDocument,
commands,
languages
} from 'vscode';
import { basename, dirname, join, parse, relative } from 'path';
import { extension } from 'mime-types';
import * as l10n from '@vscode/l10n';
import { Folders } from '../commands/Folders';
import { Wysiwyg } from '../commands/Wysiwyg';
import {
COMMAND_NAME,
DEFAULT_CONTENT_TYPE,
SETTING_CONTENT_SUPPORTED_FILETYPES,
SETTING_MEDIA_PASTE_BEHAVIOR,
SETTING_MEDIA_PASTE_FILENAME,
SETTING_MEDIA_PASTE_FOLDER
} from '../constants';
import { STATIC_FOLDER_PLACEHOLDER } from '../constants/StaticFolderPlaceholder';
import { ArticleHelper } from '../helpers/ArticleHelper';
import { FrameworkDetector } from '../helpers/FrameworkDetector';
import { Logger } from '../helpers/Logger';
import { MediaHelpers } from '../helpers/MediaHelpers';
import { Notifications } from '../helpers/Notifications';
import { Settings } from '../helpers/SettingsHelper';
import { parseWinPath } from '../helpers/parseWinPath';
import { LocalizationKey } from '../localization';
import { DashboardData } from '../models';
type PasteBehavior = 'dashboard' | 'auto' | 'disabled';
interface PasteContext {
document: TextDocument;
range: Range;
fileName: string;
mimeType: string;
contents: Uint8Array;
}
/**
* Paste edit which carries the media contents over to the resolve step
*/
class MediaPasteEdit extends DocumentPasteEdit {
constructor(title: string, kind: DocumentDropOrPasteEditKind, public ctx: PasteContext) {
super('', title, kind);
}
}
export class MediaPasteProvider implements DocumentPasteEditProvider<MediaPasteEdit> {
public static readonly kind = DocumentDropOrPasteEditKind.Empty.append('image', 'frontMatter');
public static register(subscriptions: Disposable[]) {
const supportedFiles = Settings.get<string[]>(SETTING_CONTENT_SUPPORTED_FILETYPES);
const selectors: DocumentSelector[] = [{ language: 'markdown', scheme: 'file' }];
for (const fileExt of supportedFiles || []) {
if (fileExt !== 'md' && fileExt !== 'markdown') {
selectors.push({ pattern: `**/*.${fileExt}`, scheme: 'file' });
}
}
const provider = new MediaPasteProvider();
const metadata: DocumentPasteProviderMetadata = {
providedPasteEditKinds: [MediaPasteProvider.kind],
pasteMimeTypes: ['files', 'image/*']
};
for (const selector of selectors) {
subscriptions.push(languages.registerDocumentPasteEditProvider(selector, provider, metadata));
}
}
public async provideDocumentPasteEdits(
document: TextDocument,
ranges: readonly Range[],
dataTransfer: DataTransfer,
_context: DocumentPasteEditContext,
token: CancellationToken
): Promise<MediaPasteEdit[] | undefined> {
if (MediaPasteProvider.getBehavior() === 'disabled') {
return;
}
if (!ArticleHelper.isSupportedFile(document)) {
return;
}
const image = MediaPasteProvider.getImageFile(dataTransfer);
if (!image) {
return;
}
// Read the contents while the data transfer is still valid
const contents = await image.file.data();
if (token.isCancellationRequested || !contents?.length) {
return;
}
const edit = new MediaPasteEdit(
l10n.t(LocalizationKey.providersMediaPasteTitle),
MediaPasteProvider.kind,
{
document,
range: ranges[0],
fileName: MediaPasteProvider.getFileName(image.file, image.mimeType, document),
mimeType: image.mimeType,
contents
}
);
return [edit];
}
public async resolveDocumentPasteEdit(
pasteEdit: MediaPasteEdit,
token: CancellationToken
): Promise<MediaPasteEdit> {
const { document, range, fileName, mimeType, contents } = pasteEdit.ctx;
const article = ArticleHelper.getFrontMatterFromDocument(document);
const contentType = article?.data
? await ArticleHelper.getContentType(article)
: DEFAULT_CONTENT_TYPE;
const isPageBundle = !!contentType?.pageBundle;
const folder = MediaPasteProvider.getTargetFolder(document.fileName, isPageBundle);
if (token.isCancellationRequested) {
return pasteEdit;
}
if (MediaPasteProvider.getBehavior() === 'dashboard') {
// The dashboard takes over: it stores the file, collects the metadata, and
// inserts the markup itself through `MediaHelpers.insertMediaToMarkdown`.
await commands.executeCommand(COMMAND_NAME.dashboard, {
type: 'media',
data: {
type: 'media',
pageBundle: isPageBundle,
filePath: document.fileName,
fieldName: basename(document.fileName),
position: range.start,
selection: document.getText(range),
suggestedFolder: folder,
pendingUpload: {
fileName,
contents: `data:${mimeType};base64,${Buffer.from(contents).toString('base64')}`
}
}
} as DashboardData);
return pasteEdit;
}
let absMediaPath: string | undefined;
try {
absMediaPath = await MediaHelpers.saveMediaBuffer(fileName, contents, folder);
} catch (e) {
Logger.error(`MediaPasteProvider: ${(e as Error)?.message}`);
}
if (!absMediaPath) {
Notifications.error(l10n.t(LocalizationKey.providersMediaPasteSaveFailed, folder));
return pasteEdit;
}
const relPath = MediaPasteProvider.getRelPath(absMediaPath, document.fileName, isPageBundle);
pasteEdit.insertText = MediaPasteProvider.getMarkup(relPath, document.fileName);
return pasteEdit;
}
private static getBehavior(): PasteBehavior {
return Settings.get<PasteBehavior>(SETTING_MEDIA_PASTE_BEHAVIOR) || 'dashboard';
}
/**
* Retrieve the first image from the data transfer
*/
private static getImageFile(
dataTransfer: DataTransfer
): { file: DataTransferFile; mimeType: string } | undefined {
let image: { file: DataTransferFile; mimeType: string } | undefined;
dataTransfer.forEach((item, mimeType) => {
if (image || !mimeType.startsWith('image/')) {
return;
}
const file = item.asFile();
if (file) {
image = { file, mimeType };
}
});
return image;
}
/**
* Determine the file name to use for the pasted media. Images pasted from the
* clipboard have no name of their own, so the configured template is used.
*/
private static getFileName(
file: DataTransferFile,
mimeType: string,
document: TextDocument
): string {
const { name, ext } = parse(file.name || '');
if (name && ext) {
return `${name}${ext}`;
}
const template = Settings.get<string>(SETTING_MEDIA_PASTE_FILENAME) || 'image-{date}-{time}';
const now = new Date();
const pad = (value: number) => `${value}`.padStart(2, '0');
const fileName = template
.replace(/{filename}/g, parse(document.fileName).name)
.replace(/{date}/g, `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`)
.replace(/{time}/g, `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`);
const fileExt = ext || `.${extension(mimeType) || 'png'}`;
return `${fileName}${fileExt}`;
}
/**
* Determine where the pasted media needs to be stored
*/
private static getTargetFolder(filePath: string, isPageBundle: boolean): string {
const pasteFolder = Settings.get<string>(SETTING_MEDIA_PASTE_FOLDER);
if (pasteFolder) {
return Folders.getAbsFilePath(pasteFolder);
}
const staticFolder = Folders.getStaticFolderRelativePath();
// Hexo stores its assets next to the post, in a folder named after the post
if (staticFolder === STATIC_FOLDER_PLACEHOLDER.hexo.placeholder) {
return parseWinPath(join(dirname(filePath), parse(filePath).name));
}
// Page bundles keep their media in the folder of the page itself
if (isPageBundle) {
return parseWinPath(dirname(filePath));
}
const wsFolder = Folders.getWorkspaceFolder();
return parseWinPath(
join(wsFolder?.fsPath || '', !staticFolder || staticFolder === '/' ? '' : staticFolder)
);
}
/**
* Determine the path to reference the media by, taking the static folder and
* the framework of the project into account.
*/
private static getRelPath(
absMediaPath: string,
filePath: string,
isPageBundle: boolean
): string {
if (isPageBundle) {
const relDir = parseWinPath(relative(dirname(filePath), dirname(absMediaPath)));
return parseWinPath(join(relDir, basename(absMediaPath)));
}
const wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Folders.getStaticFolderRelativePath();
let relPath =
parseWinPath(absMediaPath).split(parseWinPath(wsFolder?.fsPath || '')).pop() || '';
if (staticFolder && staticFolder !== '/') {
relPath = relPath.split(parseWinPath(staticFolder)).pop() || relPath;
}
return FrameworkDetector.relAssetPathUpdate(relPath, filePath);
}
/**
* Create the markup to insert for the pasted media
*/
private static getMarkup(relPath: string, filePath: string): string {
const mediaPath = relPath.replace(/ /g, '%20');
return Wysiwyg.getDocType(filePath) === 'asciidoc'
? `image:${mediaPath}[]`
: `![](${mediaPath})`;
}
}
+2 -3
View File
@@ -1,8 +1,7 @@
import { Config, JsonDB } from 'node-json-db';
import { Folders } from '../commands';
import { join } from 'path';
import { LocalStore } from '../constants';
import { FilesHelper, parseWinPath } from '../helpers';
import { createJsonDb, FilesHelper, parseWinPath } from '../helpers';
const PINNED_DB = '/pinned';
@@ -106,6 +105,6 @@ export class PinnedItems {
);
const dbPath = join(dbFolder, LocalStore.pinnedItemsDatabaseFile);
return new JsonDB(new Config(dbPath, true, false, '/'));
return createJsonDb(dbPath);
}
}
+68 -6
View File
@@ -2,6 +2,17 @@ import { ExtensionState } from '../constants';
import { Extension } from '../helpers';
export class Translations {
/**
* The maximum number of texts which can be sent in a single request.
* DeepL allows 50 text parameters, Azure allows 1000 array elements.
*/
private static readonly maxBatchSize = 40;
/**
* The maximum number of characters which can be sent in a single request.
* Azure allows 50.000 characters per request.
*/
private static readonly maxBatchLength = 40000;
/**
* Translates an array of text from a source language to a target language.
* @param text - The array of text to be translated.
@@ -24,15 +35,66 @@ export class Translations {
ExtensionState.Secrets.Azure.TranslatorRegion
);
if (azureAuthKey && azureRegion) {
return this.translateAzure(text, source, target, azureAuthKey, azureRegion);
if (!text || text.length === 0) {
return [];
}
if (deeplAuthKey) {
return this.translateDeepL(text, source, target, deeplAuthKey);
const translations: string[] = [];
for (const batch of Translations.createBatches(text)) {
let translated: string[] | undefined;
if (azureAuthKey && azureRegion) {
translated = await this.translateAzure(batch, source, target, azureAuthKey, azureRegion);
} else if (deeplAuthKey) {
translated = await this.translateDeepL(batch, source, target, deeplAuthKey);
} else {
return;
}
if (!translated || translated.length !== batch.length) {
throw new Error('Invalid response');
}
translations.push(...translated);
}
return;
return translations;
}
/**
* Splits the texts into batches which stay within the limits of the translation services.
* @param text - The array of text to be translated.
* @returns An array of batches.
*/
private static createBatches(text: string[]): string[][] {
const batches: string[][] = [];
let batch: string[] = [];
let batchLength = 0;
for (const value of text) {
const valueLength = value?.length || 0;
if (
batch.length > 0 &&
(batch.length >= Translations.maxBatchSize ||
batchLength + valueLength > Translations.maxBatchLength)
) {
batches.push(batch);
batch = [];
batchLength = 0;
}
batch.push(value);
batchLength += valueLength;
}
if (batch.length > 0) {
batches.push(batch);
}
return batches;
}
/**
@@ -120,7 +182,7 @@ export class Translations {
}
const data = await response.json();
if (!data.translations || data.translations.length < 3) {
if (!data.translations) {
throw new Error('Invalid response');
}