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/assets/media/styles.css b/assets/media/styles.css index b349f165..acd7388f 100644 --- a/assets/media/styles.css +++ b/assets/media/styles.css @@ -355,6 +355,11 @@ color: var(--vscode-button-secondaryForeground); } +.ext_link_block a:hover, +.ext_link_block button:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + .table__cell { overflow: hidden; } diff --git a/package.json b/package.json index 1b1592e9..683dabf2 100644 --- a/package.json +++ b/package.json @@ -598,6 +598,7 @@ "panel.globalSettings", "panel.seo", "panel.actions", + "panel.contentType", "panel.metadata", "panel.recentlyModified", "panel.otherActions", @@ -1206,6 +1207,21 @@ "title": "Authenticate", "category": "Front matter" }, + { + "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", @@ -1681,6 +1697,18 @@ { "command": "frontMatter.generateSlug", "when": "frontMatter:file:isValid == true" + }, + { + "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" } ], "view/title": [ diff --git a/src/commands/Article.ts b/src/commands/Article.ts index 4015443b..90f41f24 100644 --- a/src/commands/Article.ts +++ b/src/commands/Article.ts @@ -30,7 +30,7 @@ export class Article { return; } - const article = Article.getCurrent(); + const article = ArticleHelper.getCurrent(); if (!article) { return; @@ -375,23 +375,6 @@ export class Article { } as DashboardData); } - /** - * Get the current article - */ - private static getCurrent(): ParsedFrontMatter | undefined { - const editor = vscode.window.activeTextEditor; - if (!editor) { - return; - } - - const article = ArticleHelper.getFrontMatter(editor); - if (!article) { - return; - } - - return article; - } - /** * Update the article date and return it * @param article diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index c3331a7f..8cfe890d 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -53,4 +53,9 @@ export const COMMAND_NAME = { orderedlist: getCommandName("markup.orderedlist"), taskList: getCommandName("markup.tasklist"), options: getCommandName("markup.options"), + + // Content types + generateContentType: getCommandName("contenttype.generate"), + addMissingFields: getCommandName("contenttype.addMissingFields"), + setContentType: getCommandName("contenttype.setContentType"), }; \ No newline at end of file diff --git a/src/constants/Features.ts b/src/constants/Features.ts index eecc4df9..e4aa8629 100644 --- a/src/constants/Features.ts +++ b/src/constants/Features.ts @@ -8,6 +8,7 @@ export const FEATURE_FLAG = { metadata: "panel.metadata", recentlyModified: "panel.recentlyModified", otherActions: "panel.otherActions", + contentType: "panel.contentType", }, dashboard: { snippets: { 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 25cc9ccb..92356929 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -154,6 +154,18 @@ export async function activate(context: vscode.ExtensionContext) { const createByTemplate = vscode.commands.registerCommand(COMMAND_NAME.createByTemplate, Folders.create); const createContent = vscode.commands.registerCommand(COMMAND_NAME.createContent, Content.create); + subscriptions.push( + 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) => { @@ -193,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/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 1418bf48..b998df62 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -44,6 +44,23 @@ export class ArticleHelper { return ArticleHelper.parseFile(fileContents, document.fileName); } + /** + * Get the current article + */ + public static getCurrent(): ParsedFrontMatter | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const article = ArticleHelper.getFrontMatter(editor); + if (!article) { + return; + } + + return article; + } + /** * Retrieve the file's front matter by its path * @param filePath diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts index db032352..7eb0526e 100644 --- a/src/helpers/ContentType.ts +++ b/src/helpers/ContentType.ts @@ -1,15 +1,17 @@ +import { ModeListener } from './../listeners/general/ModeListener'; import { PagesListener } from './../listeners/dashboard'; import { ArticleHelper, Settings } from "."; -import { SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants"; +import { FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants"; import { ContentType as IContentType, DraftField, Field } from '../models'; -import { Uri, commands } from 'vscode'; +import { Uri, commands, window } from 'vscode'; import { Folders } from "../commands/Folders"; import { Questions } from "./Questions"; -import { writeFileSync } from "fs"; +import { existsSync, writeFileSync } from "fs"; 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 { @@ -92,6 +94,262 @@ export class ContentType { return Settings.get(SETTING_TAXONOMY_CONTENT_TYPES); } + /** + * Generate a content type + */ + public static async generate() { + if (!(await ContentType.verify())) { + return; + } + + 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; + } + + const override = await window.showQuickPick(["Yes", "No"], { + placeHolder: "Do you want to override the default content type?", + ignoreFocusOut: true, + title: "Override default content type" + }); + const overrideBool = override === "Yes"; + + let contentTypeName: string | undefined = `default`; + + // 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", + prompt: "Enter the name of the content type to generate", + title: "Generate Content Type", + validateInput: (value: string) => { + if (!value) { + return "Please enter a name for the content type"; + } + + const contentTypes = ContentType.getAll(); + if (contentTypes && contentTypes.find(ct => ct.name.toLowerCase() === value.toLowerCase())) { + return "A content type with this name already exists"; + } + + return null; + } + }); + + if (!contentTypeName) { + Notifications.warning(`You didn't specify a name for the content type.`); + return; + } + } + + // 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 (overrideBool) { + const index = contentTypes.findIndex(ct => ct.name === contentTypeName); + contentTypes[index].fields = fields; + } else { + contentTypes.push(newContentType); + } + + Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true); + + const configPath = Settings.projectConfigPath; + 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() { + if (!(await ContentType.verify())) { + return; + } + + 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() { + if (!(await ContentType.verify())) { + return; + } + + 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); + } + + /** + * Generate the fields from the data + * @param data + * @param fields + * @returns + */ + private static generateFields(data: any, fields: any[] = []) { + 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({ + title: field, + name: field, + type: "tags", + } as Field); + } else if (field.toLowerCase() === "category" || field.toLowerCase() === "categories") { + fields.push({ + title: field, + name: field, + type: "categories", + } as Field); + } else { + fields.push({ + title: field, + name: field, + type: "choice", + choices: fieldData + } as Field); + } + } else if (fieldData && fieldData instanceof Array && fieldData.length > 0 && typeof fieldData[0] === "object") { + const newFields = ContentType.generateFields(fieldData); + fields.push({ + title: field, + name: field, + type: "block", + fields: newFields + } as Field); + } else if (fieldData && fieldData instanceof Object) { + const newFields = ContentType.generateFields(fieldData); + fields.push({ + title: field, + name: field, + type: "fields", + fields: newFields + } as Field); + } else { + if (!isNaN(new Date(fieldData).getDate())) { + fields.push({ + title: field, + name: field, + type: "datetime" + } as Field); + } else if (field.toLowerCase() === "draft") { + fields.push({ + title: field, + name: field, + type: "draft" + } as Field); + } else if (field.toLowerCase() === "slug") { + // Do nothing + } else { + fields.push({ + title: field, + name: field, + type: typeof fieldData + } as Field); + } + } + } + + return fields; + } + /** * Create a new file with the specified content type * @param contentType @@ -167,4 +425,18 @@ export class ContentType { return data; } + + /** + * Verify if the content type feature is enabled + * @returns + */ + private static async verify() { + const hasFeature = await ModeListener.hasFeature(FEATURE_FLAG.panel.contentType); + if (!hasFeature) { + Notifications.warning(`The content type actions are not available in this mode.`); + return false; + } + + return true; + } } \ No newline at end of file diff --git a/src/helpers/SettingsHelper.ts b/src/helpers/SettingsHelper.ts index 7382aed3..0c93a2d7 100644 --- a/src/helpers/SettingsHelper.ts +++ b/src/helpers/SettingsHelper.ts @@ -328,7 +328,7 @@ export class Settings { * Get the project config path * @returns */ - private static get projectConfigPath() { + public static get projectConfigPath() { const wsFolder = Folders.getWorkspaceFolder(); if (wsFolder) { const fmConfig = join(wsFolder.fsPath, Settings.globalFile); 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/general/ModeListener.ts b/src/listeners/general/ModeListener.ts index 636499d2..25989474 100644 --- a/src/listeners/general/ModeListener.ts +++ b/src/listeners/general/ModeListener.ts @@ -51,6 +51,30 @@ export class ModeListener extends BaseListener { } } + /** + * Check if the mode has the feature enabled + * @param feature + * @returns + */ + public static async hasFeature(feature: string) { + const modes = Settings.get(SETTING_GLOBAL_MODES); + + if (!modes || modes.length === 0) { + return true; + } + + const activeMode = ModeSwitch.getMode(); + if (activeMode) { + const mode = modes.find(m => m.id === activeMode); + return mode?.features.find(f => f === feature); + } + + return true; + } + + /** + * Reset the context + */ public static async resetEnablement() { await commands.executeCommand('setContext', CONTEXT.isSnippetsDashboardEnabled, true); await commands.executeCommand('setContext', CONTEXT.isDataDashboardEnabled, true); 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/ViewPanel.tsx b/src/panelWebView/ViewPanel.tsx index 95f148d6..0cd56ead 100644 --- a/src/panelWebView/ViewPanel.tsx +++ b/src/panelWebView/ViewPanel.tsx @@ -70,7 +70,8 @@ export const ViewPanel: React.FunctionComponent = (props: React settings={settings} metadata={metadata} focusElm={focusElm} - unsetFocus={unsetFocus} /> + unsetFocus={unsetFocus} + features={mode?.features || []} /> diff --git a/src/panelWebView/components/ContentType/ContentTypeValidator.tsx b/src/panelWebView/components/ContentType/ContentTypeValidator.tsx new file mode 100644 index 00000000..f084a9bf --- /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 create, update, or set the content type for this content?

+ +
+ Create 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..267e92c3 100644 --- a/src/panelWebView/components/Metadata.tsx +++ b/src/panelWebView/components/Metadata.tsx @@ -4,12 +4,12 @@ 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'; +import { FeatureFlag } from '../../components/features/FeatureFlag'; +import { FEATURE_FLAG } from '../../constants'; export interface IMetadata { [prop: string]: string[] | string | null | IMetadata; @@ -18,10 +18,11 @@ export interface IMetadataProps { settings: PanelSettings | undefined; metadata: IMetadata; focusElm: TagType | null; + features: string[]; unsetFocus: () => void; } -const Metadata: React.FunctionComponent = ({settings, metadata, focusElm, unsetFocus}: React.PropsWithChildren) => { +const Metadata: React.FunctionComponent = ({settings, features, metadata, focusElm, unsetFocus}: React.PropsWithChildren) => { const contentType = useContentType(settings, metadata); const sendUpdate = (field: string | undefined, value: any, parents: string[]) => { @@ -80,6 +81,12 @@ 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..ba088adc 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-sideBar-foreground); +} + /* File field */ .metadata_field__file__button.not_empty { display: flex;