diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4887f1..c14c4e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### 🐞 Fixes +- Updated JSON schema link to supported version by VS Code (draft-07) - Hide the view mode action from the Front Matter panel if no custom modes are defined - Fix in decode base64 uploaded video files - Fix for a lightbox on other types of documents (pdf, etc.) diff --git a/package.json b/package.json index 1e76debb..0b13de08 100644 --- a/package.json +++ b/package.json @@ -1207,10 +1207,20 @@ "category": "Front matter" }, { - "command": "frontMatter.generate.contenttype", + "command": "frontMatter.contenttype.generate", "title": "Generate content type from current file", "category": "Front matter" }, + { + "command": "frontMatter.contenttype.addMissingFields", + "title": "Add missing fields from front matter to content type", + "category": "Front matter" + }, + { + "command": "frontMatter.contenttype.setContentType", + "title": "Set the content type to use for the current file", + "category": "Front matter" + }, { "command": "frontMatter.markup.blockquote", "title": "Blockquote", @@ -1688,7 +1698,15 @@ "when": "frontMatter:file:isValid == true" }, { - "command": "frontMatter.generate.contenttype", + "command": "frontMatter.contenttype.generate", + "when": "frontMatter:file:isValid == true" + }, + { + "command": "frontMatter.contenttype.addMissingFields", + "when": "frontMatter:file:isValid == true" + }, + { + "command": "frontMatter.contenttype.setContentType", "when": "frontMatter:file:isValid == true" } ], diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index a55eb954..8cfe890d 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -55,5 +55,7 @@ export const COMMAND_NAME = { options: getCommandName("markup.options"), // Content types - generateContentType: getCommandName("generate.contenttype"), + generateContentType: getCommandName("contenttype.generate"), + addMissingFields: getCommandName("contenttype.addMissingFields"), + setContentType: getCommandName("contenttype.setContentType"), }; \ No newline at end of file diff --git a/src/constants/TelemetryEvent.ts b/src/constants/TelemetryEvent.ts index b04b0232..0c6c43cd 100644 --- a/src/constants/TelemetryEvent.ts +++ b/src/constants/TelemetryEvent.ts @@ -26,6 +26,11 @@ export const TelemetryEvent = { updateMediaMetadata: 'updateMediaMetadata', openExplorerView: 'openExplorerView', + // Content types + generateContentType: 'generateContentType', + addMissingFields: 'addMissingFields', + setContentType: 'setContentType', + // Custom scripts runCustomScript: 'runCustomScript', runMediaScript: 'runMediaScript', diff --git a/src/extension.ts b/src/extension.ts index b761dfba..92356929 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -158,6 +158,14 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand(COMMAND_NAME.generateContentType, ContentType.generate) ); + subscriptions.push( + vscode.commands.registerCommand(COMMAND_NAME.addMissingFields, ContentType.addMissingFields) + ); + + subscriptions.push( + vscode.commands.registerCommand(COMMAND_NAME.setContentType, ContentType.setContentType) + ); + // Initialize command Template.init(); const projectInit = vscode.commands.registerCommand(COMMAND_NAME.init, async (cb: Function) => { @@ -197,7 +205,13 @@ export async function activate(context: vscode.ExtensionContext) { subscriptions.push(vscode.window.onDidChangeActiveTextEditor(() => triggerShowDraftStatus(`onDidChangeActiveTextEditor`))); subscriptions.push(vscode.window.onDidChangeTextEditorSelection((e) => { if (e.kind === vscode.TextEditorSelectionChangeKind.Mouse) { - triggerShowDraftStatus(`onDidChangeTextEditorSelection`); + statusDebouncer(() => triggerShowDraftStatus(`onDidChangeTextEditorSelection`), 200); + } + })); + subscriptions.push(vscode.workspace.onDidChangeTextDocument((TextDocumentChangeEvent) => { + const filePath = TextDocumentChangeEvent.document.uri.fsPath; + if (filePath && !filePath.toLowerCase().startsWith(`extension-output`)) { + statusDebouncer(() => triggerShowDraftStatus(`onDidChangeTextEditorSelection`), 200); } })); diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts index 0834bf4a..829ac785 100644 --- a/src/helpers/ContentType.ts +++ b/src/helpers/ContentType.ts @@ -10,6 +10,7 @@ import { Notifications } from "./Notifications"; import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType"; import { Telemetry } from './Telemetry'; import { processKnownPlaceholders } from './PlaceholderHelper'; +import { basename } from 'path'; export class ContentType { @@ -96,8 +97,13 @@ export class ContentType { * Generate a content type */ public static async generate() { + Telemetry.send(TelemetryEvent.generateContentType); + const content = ArticleHelper.getCurrent(); + const editor = window.activeTextEditor; + const filePath = editor?.document.uri.fsPath; + if (!content || !content.data) { Notifications.warning(`No front matter data found to generate a content type.`); return; @@ -108,10 +114,12 @@ export class ContentType { ignoreFocusOut: true, title: "Override default content type" }); + const overrideBool = override === "Yes"; let contentTypeName: string | undefined = `default`; - if (override === "No") { + // Ask for the new content type name + if (!overrideBool) { contentTypeName = await window.showInputBox({ ignoreFocusOut: true, placeHolder: "Enter the name of the content type to generate", @@ -137,16 +145,43 @@ export class ContentType { } } + // Ask if the content type needs to be used as a page bundle + let pageBundle = false; + const fileName = filePath ? basename(filePath) : undefined; + if (fileName?.startsWith(`index.`)) { + const pageBundleAnswer = await window.showQuickPick(["Yes", "No"], { + placeHolder: "Do you want to use this content type as a page bundle?", + ignoreFocusOut: true, + title: "Use as page bundle" + }); + pageBundle = pageBundleAnswer === "Yes"; + } + const fields = ContentType.generateFields(content.data); + if (!overrideBool && !fields.some(f => f.name === "type")) { + fields.push({ + name: "type", + type: "string", + default: contentTypeName, + hidden: true + } as Field); + } + + // Update the type field in the page + if (!overrideBool && editor) { + content.data["type"] = contentTypeName; + ArticleHelper.update(editor, content); + } const newContentType: IContentType = { name: contentTypeName, + pageBundle, fields }; const contentTypes = ContentType.getAll() || []; - if (override === "Yes") { + if (overrideBool) { const index = contentTypes.findIndex(ct => ct.name === contentTypeName); contentTypes[index].fields = fields; } else { @@ -156,11 +191,71 @@ export class ContentType { Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true); const configPath = Settings.projectConfigPath; - const notificationAction = await Notifications.info(`Content type ${contentTypeName} has been ${override === "Yes" ? `updated` : `generated`}.`, configPath && existsSync(configPath) ? `Open settings` : undefined); + const notificationAction = await Notifications.info(`Content type ${contentTypeName} has been ${overrideBool ? `updated` : `generated`}.`, configPath && existsSync(configPath) ? `Open settings` : undefined); if (notificationAction === "Open settings" && configPath && existsSync(configPath)) { commands.executeCommand('vscode.open', Uri.file(configPath)); - } + } + } + + /** + * Add missing fields to the content type + */ + public static async addMissingFields() { + Telemetry.send(TelemetryEvent.addMissingFields); + + const content = ArticleHelper.getCurrent(); + + if (!content || !content.data) { + Notifications.warning(`No front matter data found to add missing fields.`); + return; + } + + const contentType = ArticleHelper.getContentType(content?.data); + const updatedFields = ContentType.generateFields(content.data, contentType.fields); + + const contentTypes = ContentType.getAll() || []; + const index = contentTypes.findIndex(ct => ct.name === contentType.name); + contentTypes[index].fields = updatedFields; + + Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true); + + const configPath = Settings.projectConfigPath; + const notificationAction = await Notifications.info(`Content type ${contentType.name} has been updated.`, configPath && existsSync(configPath) ? `Open settings` : undefined); + + if (notificationAction === "Open settings" && configPath && existsSync(configPath)) { + commands.executeCommand('vscode.open', Uri.file(configPath)); + } + } + + /** + * Set the content type to be used for the current file + */ + public static async setContentType() { + Telemetry.send(TelemetryEvent.setContentType); + + const content = ArticleHelper.getCurrent(); + const contentTypes = ContentType.getAll() || []; + + if (!content || !content.data) { + Notifications.warning(`No front matter data found to set the content type.`); + return; + } + + const ctAnswer = await window.showQuickPick(contentTypes.map(ct => ct.name), { + title: "Select the content type", + ignoreFocusOut: true, + placeHolder: "Which content type would you like to use?" + }); + + if (!ctAnswer) { + return; + } + + content.data.type = ctAnswer; + + const editor = window.activeTextEditor; + ArticleHelper.update(editor!, content); } /** @@ -173,6 +268,10 @@ export class ContentType { for (const field in data) { const fieldData = data[field]; + if (fields.some(f => f.name === field)) { + continue; + } + if (fieldData && fieldData instanceof Array && fieldData.length > 0 && typeof fieldData[0] === "string") { if (field.toLowerCase() === "tag" || field.toLowerCase() === "tags") { fields.push({ diff --git a/src/hooks/useContentType.tsx b/src/hooks/useContentType.tsx index 02111a63..f474bee4 100644 --- a/src/hooks/useContentType.tsx +++ b/src/hooks/useContentType.tsx @@ -21,7 +21,7 @@ export default function useContentType(settings: PanelSettings | Settings | unde setContentType(ct || DEFAULT_CONTENT_TYPE) } - }, [settings?.contentTypes, metadata?.data]); + }, [settings?.contentTypes, metadata?.type]); return contentType; } \ No newline at end of file diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index faf5ba59..f696ada5 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -43,6 +43,12 @@ export class DataListener extends BaseListener { case CommandToCode.updatePlaceholder: this.updatePlaceholder(msg?.data?.field, msg?.data?.value, msg?.data?.title); break; + case CommandToCode.generateContentType: + commands.executeCommand(COMMAND_NAME.generateContentType); + case CommandToCode.addMissingFields: + commands.executeCommand(COMMAND_NAME.addMissingFields); + case CommandToCode.setContentType: + commands.executeCommand(COMMAND_NAME.setContentType); } } diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index c0946944..30ab1986 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -34,4 +34,7 @@ export enum CommandToCode { getImageUrl = "get-image-url", updatePlaceholder = "update-placeholder", getMode = "get-mode", + generateContentType = "generate-content-type", + addMissingFields = "add-missing-fields", + setContentType = "set-content-type", } \ No newline at end of file diff --git a/src/panelWebView/components/ContentType/ContentTypeValidator.tsx b/src/panelWebView/components/ContentType/ContentTypeValidator.tsx new file mode 100644 index 00000000..b50c758a --- /dev/null +++ b/src/panelWebView/components/ContentType/ContentTypeValidator.tsx @@ -0,0 +1,74 @@ +import { VSCodeButton, VSCodeDivider } from '@vscode/webview-ui-toolkit/react'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { MessageHelper } from '../../../helpers/MessageHelper'; +import { Field } from '../../../models'; +import { CommandToCode } from '../../CommandToCode'; +import { IMetadata } from '../Metadata'; +import { VsLabel } from '../VscodeComponents'; + +export interface IContentTypeValidatorProps { + fields: Field[]; + metadata: IMetadata +} + +const fieldsToIgnore = [`filePath`, `articleDetails`, `slug`]; + +export const ContentTypeValidator: React.FunctionComponent = ({ fields, metadata}: React.PropsWithChildren) => { + + const isValid = useMemo(() => { + const metadataFields = Object.keys(metadata).filter(key => !fieldsToIgnore.includes(key)); + + for (const mField of metadataFields) { + if (!fields.find(field => field.name === mField)) { + return false; + } + } + + return true; + }, [fields, metadata]); + + + const generateContentType = () => { + MessageHelper.sendMessage(CommandToCode.generateContentType); + }; + + const addMissingFields = () => { + MessageHelper.sendMessage(CommandToCode.addMissingFields); + }; + + const setContentType = () => { + MessageHelper.sendMessage(CommandToCode.setContentType); + }; + + + if (isValid) { + return null; + } + + return ( +
+ +
+ + + Content type +
+
+ +

We noticed field differences between the content type and the front matter data.

+ +

Would you like to generate or update the content type for this page?

+ +
+ Generate content type + + Add missing fields + + Set content type +
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/panelWebView/components/Metadata.tsx b/src/panelWebView/components/Metadata.tsx index d866ef7e..1ae6ce24 100644 --- a/src/panelWebView/components/Metadata.tsx +++ b/src/panelWebView/components/Metadata.tsx @@ -4,12 +4,10 @@ import { CommandToCode } from '../CommandToCode'; import { MessageHelper } from '../../helpers/MessageHelper'; import { TagType } from '../TagType'; import { Collapsible } from './Collapsible'; -import { SymbolKeywordIcon } from './Icons/SymbolKeywordIcon'; -import { TagPicker } from './TagPicker'; import "react-datepicker/dist/react-datepicker.css"; import useContentType from '../../hooks/useContentType'; -import FieldBoundary from './ErrorBoundary/FieldBoundary'; import { WrapperField } from './Fields/WrapperField'; +import { ContentTypeValidator } from './ContentType/ContentTypeValidator'; export interface IMetadata { [prop: string]: string[] | string | null | IMetadata; @@ -80,6 +78,10 @@ const Metadata: React.FunctionComponent = ({settings, metadata, return ( + + { renderFields(contentType?.fields || [], metadata) } diff --git a/src/panelWebView/styles.css b/src/panelWebView/styles.css index 4144d90d..29383c63 100644 --- a/src/panelWebView/styles.css +++ b/src/panelWebView/styles.css @@ -253,6 +253,33 @@ display: none; } +/* Metadata section - Content type */ +.metadata_field__alert svg { + color: var(--vscode-editorWarning-foreground) +} + +.hint { + margin-bottom: 1rem; +} + +.hint__buttons vscode-button { + display: block; + margin-bottom: .5rem; + text-align: center; +} + +.hint__buttons vscode-button:last-child { + margin-bottom: 0; +} + +vscode-divider { + margin-top: 1rem; +} + +.inline_hint { + color: var(--vscode-editorInlayHint-foreground); +} + /* File field */ .metadata_field__file__button.not_empty { display: flex;