Merge branch 'poc/generate-ct' into dev

This commit is contained in:
Elio Struyf
2022-04-26 12:05:28 +02:00
19 changed files with 506 additions and 29 deletions
+1
View File
@@ -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.)
+5
View File
@@ -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;
}
+28
View File
@@ -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": [
+1 -18
View File
@@ -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
+5
View File
@@ -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"),
};
+1
View File
@@ -8,6 +8,7 @@ export const FEATURE_FLAG = {
metadata: "panel.metadata",
recentlyModified: "panel.recentlyModified",
otherActions: "panel.otherActions",
contentType: "panel.contentType",
},
dashboard: {
snippets: {
+5
View File
@@ -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',
+19 -1
View File
@@ -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);
}
}));
+17
View File
@@ -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
+275 -3
View File
@@ -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<IContentType[]>(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;
}
}
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+24
View File
@@ -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<Mode[]>(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);
+6
View File
@@ -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);
}
}
+3
View File
@@ -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",
}
+2 -1
View File
@@ -70,7 +70,8 @@ export const ViewPanel: React.FunctionComponent<IViewPanelProps> = (props: React
settings={settings}
metadata={metadata}
focusElm={focusElm}
unsetFocus={unsetFocus} />
unsetFocus={unsetFocus}
features={mode?.features || []} />
</FeatureFlag>
<FeatureFlag features={mode?.features || []} flag={FEATURE_FLAG.panel.recentlyModified}>
@@ -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<IContentTypeValidatorProps> = ({ fields, metadata}: React.PropsWithChildren<IContentTypeValidatorProps>) => {
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 (
<div className='hint'>
<VsLabel>
<div className={`metadata_field__label metadata_field__alert`}>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fillRule="evenodd" clipRule="evenodd" d="M7.56 1h.88l6.54 12.26-.44.74H1.44L1 13.26 7.56 1zM8 2.28L2.28 13H13.7L8 2.28zM8.625 12v-1h-1.25v1h1.25zm-1.25-2V6h1.25v4h-1.25z"/></svg>
<span>Content type</span>
</div>
</VsLabel>
<p className='inline_hint'>We noticed field differences between the content type and the front matter data.</p>
<p className='inline_hint'>Would you like to create, update, or set the content type for this content?</p>
<div className='hint__buttons'>
<VSCodeButton appearance={`secondary`} onClick={generateContentType}>Create content type</VSCodeButton>
<VSCodeButton appearance={`secondary`} onClick={addMissingFields}>Add missing fields</VSCodeButton>
<VSCodeButton appearance={`secondary`} onClick={setContentType}>Set content type</VSCodeButton>
</div>
<VSCodeDivider />
</div>
);
};
+11 -4
View File
@@ -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<IMetadataProps> = ({settings, metadata, focusElm, unsetFocus}: React.PropsWithChildren<IMetadataProps>) => {
const Metadata: React.FunctionComponent<IMetadataProps> = ({settings, features, metadata, focusElm, unsetFocus}: React.PropsWithChildren<IMetadataProps>) => {
const contentType = useContentType(settings, metadata);
const sendUpdate = (field: string | undefined, value: any, parents: string[]) => {
@@ -80,6 +81,12 @@ const Metadata: React.FunctionComponent<IMetadataProps> = ({settings, metadata,
return (
<Collapsible id={`tags`} title="Metadata" className={`inherit z-20`}>
<FeatureFlag features={features || []} flag={FEATURE_FLAG.panel.contentType}>
<ContentTypeValidator
fields={contentType?.fields || []}
metadata={metadata} />
</FeatureFlag>
{
renderFields(contentType?.fields || [], metadata)
}
+27
View File
@@ -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;