diff --git a/CHANGELOG.md b/CHANGELOG.md index f7017bae..9c28037a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - [#121](https://github.com/estruyf/vscode-front-matter/issues/121): Choice fields support ID/title objects as well as a regular string - [#122](https://github.com/estruyf/vscode-front-matter/issues/122): Update the filenames of your media - [#124](https://github.com/estruyf/vscode-front-matter/issues/124): Add new `isPreviewImage` property to the content type field to specify custom preview images +- [#126](https://github.com/estruyf/vscode-front-matter/issues/126): Create new content from the available content types - [#127](https://github.com/estruyf/vscode-front-matter/issues/127): Title bar action added to open the dashboard ### 🐞 Fixes diff --git a/package.json b/package.json index 4a9f50aa..6d8497d1 100644 --- a/package.json +++ b/package.json @@ -334,7 +334,7 @@ "type": "datetime" }, { - "title": "Article preview", + "title": "Content preview", "name": "preview", "type": "image" }, @@ -503,7 +503,7 @@ }, { "command": "frontMatter.generateSlug", - "title": "Generate slug based on article title", + "title": "Generate slug based on content title", "category": "Front matter" }, { @@ -518,7 +518,7 @@ }, { "command": "frontMatter.insertImage", - "title": "Insert image into article", + "title": "Insert image into your content", "category": "Front matter", "icon": "$(device-camera)" }, @@ -529,7 +529,7 @@ }, { "command": "frontMatter.createContent", - "title": "New article from template", + "title": "Create new content from defined content type or template", "category": "Front matter" }, { @@ -540,7 +540,7 @@ }, { "command": "frontMatter.preview", - "title": "Preview article", + "title": "Preview content", "category": "Front matter" }, { diff --git a/src/commands/Content.ts b/src/commands/Content.ts new file mode 100644 index 00000000..6f8c514d --- /dev/null +++ b/src/commands/Content.ts @@ -0,0 +1,31 @@ +import { commands, QuickPickItem, window } from 'vscode'; +import { COMMAND_NAME } from '../constants'; + +export class Content { + + public static async create() { + + const options: QuickPickItem[] = [{ + label: "Create content by content type", + description: "Select if you want to create new content by the available content type(s)" + }, { + label: "Create content by template", + description: "Select if you want to create new content by the available template(s)" + } as QuickPickItem]; + + const selectedOption = await window.showQuickPick(options, { + placeHolder: `Select how you want to create your new content`, + canPickMany: false + }); + + if (selectedOption) { + if (selectedOption.label === options[0].label) { + commands.executeCommand(COMMAND_NAME.createByContentType); + } else { + commands.executeCommand(COMMAND_NAME.createByTemplate); + } + } + + return; + } +} \ No newline at end of file diff --git a/src/commands/Dashboard.ts b/src/commands/Dashboard.ts index 16e551f0..5665e286 100644 --- a/src/commands/Dashboard.ts +++ b/src/commands/Dashboard.ts @@ -140,6 +140,12 @@ export class Dashboard { case DashboardMessage.createContent: await commands.executeCommand(COMMAND_NAME.createContent); break; + case DashboardMessage.createByContentType: + await commands.executeCommand(COMMAND_NAME.createByContentType); + break; + case DashboardMessage.createByTemplate: + await commands.executeCommand(COMMAND_NAME.createByTemplate); + break; case DashboardMessage.updateSetting: Dashboard.updateSetting(msg.data); break; diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 66097bb1..609075bc 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -1,3 +1,4 @@ +import { Questions } from './../helpers/Questions'; import { SETTINGS_CONTENT_PAGE_FOLDERS } from './../constants/settings'; import { commands, Uri, workspace, window } from "vscode"; import { basename, join } from "path"; @@ -17,27 +18,12 @@ export class Folders { * @returns */ public static async create() { - const folders = Folders.get(); - - if (!folders || folders.length === 0) { - Notifications.warning(`There are no known content locations defined in this project.`); - return; - } - - let selectedFolder: string | undefined; - if (folders.length > 1) { - selectedFolder = await window.showQuickPick(folders.map(f => f.title), { - placeHolder: `Select where you want to create your content` - }); - } else { - selectedFolder = folders[0].title; - } - + const selectedFolder = await Questions.SelectContentFolder(); if (!selectedFolder) { - Notifications.warning(`You didn't select a place where you wanted to create your content.`); return; } + const folders = Folders.get(); const location = folders.find(f => f.title === selectedFolder); if (location) { const folderPath = Folders.getFolderPath(Uri.file(location.path)); diff --git a/src/commands/Template.ts b/src/commands/Template.ts index 7c205a60..141b3f9b 100644 --- a/src/commands/Template.ts +++ b/src/commands/Template.ts @@ -1,3 +1,4 @@ +import { Questions } from './../helpers/Questions'; import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; @@ -67,7 +68,7 @@ export class Template { ["yes", "no"], { canPickMany: false, - placeHolder: `Do you want to keep the article its contents for the template?`, + placeHolder: `Do you want to keep the contents for the template?`, } ); @@ -113,26 +114,22 @@ export class Template { } const selectedTemplate = await vscode.window.showQuickPick(templates.map(t => path.basename(t.fsPath)), { - placeHolder: `Select the article template to use` + placeHolder: `Select the content template to use` }); if (!selectedTemplate) { Notifications.warning(`No template selected.`); return; } - const titleValue = await vscode.window.showInputBox({ - prompt: `What would you like to use as a title for the new article?`, - placeHolder: `Article title` - }); + const titleValue = await Questions.ContentTitle(); if (!titleValue) { - Notifications.warning(`You did not specify an article title.`); return; } // Start the template read const template = templates.find(t => t.fsPath.endsWith(selectedTemplate)); if (!template) { - Notifications.warning(`Article template could not be found.`); + Notifications.warning(`Content template could not be found.`); return; } @@ -180,7 +177,7 @@ export class Template { vscode.window.showTextDocument(txtDoc); } - Notifications.info(`Your new article has been created.`); + Notifications.info(`Your new content has been created.`); } /** diff --git a/src/constants/Extension.ts b/src/constants/Extension.ts index e3098bbf..316fc480 100644 --- a/src/constants/Extension.ts +++ b/src/constants/Extension.ts @@ -25,6 +25,8 @@ export const COMMAND_NAME = { registerFolder: getCommandName("registerFolder"), unregisterFolder: getCommandName("unregisterFolder"), createContent: getCommandName("createContent"), + createByContentType: getCommandName("createByContentType"), + createByTemplate: getCommandName("createByTemplate"), createTemplate: getCommandName("createTemplate"), collapseSections: getCommandName("collapseSections"), preview: getCommandName("preview"), diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index 71b24f08..034c1bb2 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -4,6 +4,8 @@ export enum DashboardMessage { openFile = 'openFile', getTheme = 'getTheme', createContent = 'createContent', + createByContentType = 'createByContentType', + createByTemplate = 'createByTemplate', updateSetting = 'updateSetting', initializeProject = 'initializeProject', reload = 'reload', diff --git a/src/dashboardWebView/components/ChoiceButton.tsx b/src/dashboardWebView/components/ChoiceButton.tsx new file mode 100644 index 00000000..430d0b6c --- /dev/null +++ b/src/dashboardWebView/components/ChoiceButton.tsx @@ -0,0 +1,52 @@ +import { Menu } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/outline'; +import * as React from 'react'; +import { MenuItem, MenuItems } from './Menu'; + +export interface IChoiceButtonProps { + title: string; + choices: { + title: string; + disabled?: boolean; + onClick: () => void; + }[]; + disabled?: boolean; + onClick: () => void; +} + +export const ChoiceButton: React.FunctionComponent = ({onClick, disabled, choices, title}: React.PropsWithChildren) => { + return ( + + + + + + Open options + + + +
+ {choices.map((choice) => ( + + ))} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Header/Header.tsx b/src/dashboardWebView/components/Header/Header.tsx index 9eff527a..e78a57a6 100644 --- a/src/dashboardWebView/components/Header/Header.tsx +++ b/src/dashboardWebView/components/Header/Header.tsx @@ -6,7 +6,6 @@ import { Folders } from './Folders'; import { Settings } from '../../models'; import { DashboardMessage } from '../../DashboardMessage'; import { Startup } from '../Startup'; -import { Button } from '../Button'; import { Navigation } from '../Navigation'; import { Grouping } from '.'; import { ViewSwitch } from './ViewSwitch'; @@ -17,6 +16,7 @@ import { ClearFilters } from './ClearFilters'; import { MarkdownIcon } from '../../../panelWebView/components/Icons/MarkdownIcon'; import { PhotographIcon } from '@heroicons/react/outline'; import { Pagination } from '../Media/Pagination'; +import { ChoiceButton } from '../ChoiceButton'; export interface IHeaderProps { settings: Settings | null; @@ -37,6 +37,14 @@ export const Header: React.FunctionComponent = ({totalPages, folde Messenger.send(DashboardMessage.createContent); }; + const createByContentType = () => { + Messenger.send(DashboardMessage.createByContentType); + }; + + const createByTemplate = () => { + Messenger.send(DashboardMessage.createByTemplate); + }; + return (
@@ -60,7 +68,19 @@ export const Header: React.FunctionComponent = ({totalPages, folde
- +
diff --git a/src/dashboardWebView/components/Menu/MenuItem.tsx b/src/dashboardWebView/components/Menu/MenuItem.tsx index 204dd98e..7c5d6d69 100644 --- a/src/dashboardWebView/components/Menu/MenuItem.tsx +++ b/src/dashboardWebView/components/Menu/MenuItem.tsx @@ -4,16 +4,18 @@ import * as React from 'react'; export interface IMenuItemProps { title: string; value: any; - isCurrent: boolean; + isCurrent?: boolean; + disabled?: boolean; onClick: (value: any) => void; } -export const MenuItem: React.FunctionComponent = ({title, value, isCurrent, onClick}: React.PropsWithChildren) => { +export const MenuItem: React.FunctionComponent = ({title, value, isCurrent, disabled, onClick}: React.PropsWithChildren) => { return ( diff --git a/src/dashboardWebView/components/Menu/MenuItems.tsx b/src/dashboardWebView/components/Menu/MenuItems.tsx index cbddc01e..77c3b185 100644 --- a/src/dashboardWebView/components/Menu/MenuItems.tsx +++ b/src/dashboardWebView/components/Menu/MenuItems.tsx @@ -2,9 +2,11 @@ import { Menu, Transition } from '@headlessui/react'; import * as React from 'react'; import { Fragment } from 'react'; -export interface IMenuItemsProps {} +export interface IMenuItemsProps { + widthClass?: string; +} -export const MenuItems: React.FunctionComponent = ({children}: React.PropsWithChildren) => { +export const MenuItems: React.FunctionComponent = ({widthClass, children}: React.PropsWithChildren) => { return ( = ({children}: leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
{children}
diff --git a/src/extension.ts b/src/extension.ts index a5bde400..c1c90fe7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,3 +1,4 @@ +import { ContentType } from './helpers/ContentType'; import { Dashboard } from './commands/Dashboard'; import * as vscode from 'vscode'; import { Article, Settings, StatusListener } from './commands'; @@ -13,6 +14,7 @@ import { ExplorerView } from './explorerView/ExplorerView'; import { Extension } from './helpers/Extension'; import { DashboardData } from './models/DashboardData'; import { Settings as SettingsHelper } from './helpers'; +import { Content } from './commands/Content'; let frontMatterStatusBar: vscode.StatusBarItem; let statusDebouncer: { (fnc: any, time: number): void; }; @@ -104,7 +106,9 @@ export async function activate(context: vscode.ExtensionContext) { const unregisterFolder = vscode.commands.registerCommand(COMMAND_NAME.unregisterFolder, Folders.unregister); - const createContent = vscode.commands.registerCommand(COMMAND_NAME.createContent, Folders.create); + const createByContentType = vscode.commands.registerCommand(COMMAND_NAME.createByContentType, ContentType.createContent); + const createByTemplate = vscode.commands.registerCommand(COMMAND_NAME.createByTemplate, Folders.create); + const createContent = vscode.commands.registerCommand(COMMAND_NAME.createContent, Content.create); // Initialize command Template.init(); @@ -184,6 +188,8 @@ export async function activate(context: vscode.ExtensionContext) { registerFolder, unregisterFolder, createContent, + createByContentType, + createByTemplate, projectInit, collapseAll ); diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts new file mode 100644 index 00000000..2748198f --- /dev/null +++ b/src/helpers/ContentType.ts @@ -0,0 +1,96 @@ +import { ArticleHelper, Settings } from "."; +import { SETTING_TAXONOMY_CONTENT_TYPES, SETTING_TEMPLATES_PREFIX } from "../constants"; +import { ContentType as IContentType } from '../models'; +import { Uri, workspace, window } from 'vscode'; +import { Folders } from "../commands/Folders"; +import { Questions } from "./Questions"; +import sanitize from '../helpers/Sanitize'; +import { format } from "date-fns"; +import { join } from "path"; +import { existsSync, writeFileSync } from "fs"; +import { Notifications } from "./Notifications"; +import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType"; + + +export class ContentType { + + + public static async createContent() { + const selectedContentType = await Questions.SelectContentType(); + if (!selectedContentType) { + return; + } + + const selectedFolder = await Questions.SelectContentFolder(); + if (!selectedFolder) { + return; + } + + const contentTypes = ContentType.getAll(); + const folders = Folders.get(); + + const location = folders.find(f => f.title === selectedFolder); + if (contentTypes && location) { + const folderPath = Folders.getFolderPath(Uri.file(location.path)); + const contentType = contentTypes.find(ct => ct.name === selectedContentType); + if (folderPath && contentType) { + ContentType.create(contentType, folderPath); + } + } + } + + /** + * Retrieve all content types + * @returns + */ + public static getAll() { + return Settings.get(SETTING_TAXONOMY_CONTENT_TYPES); + } + + private static async create(contentType: IContentType, folderPath: string) { + const prefix = Settings.get(SETTING_TEMPLATES_PREFIX); + + const titleValue = await Questions.ContentTitle(); + if (!titleValue) { + return; + } + + const sanitizedName = sanitize(titleValue.toLowerCase().replace(/ /g, "-")); + let newFileName = `${sanitizedName}.md`; + + if (prefix && typeof prefix === "string") { + newFileName = `${format(new Date(), prefix)}-${newFileName}`; + } + + const newFilePath = join(folderPath, newFileName); + if (existsSync(newFilePath)) { + Notifications.warning(`Content with the title already exists. Please specify a new title.`); + return; + } + + const data: any = {}; + + for (const field of contentType.fields) { + if (field.name === "title") { + data[field.name] = titleValue; + } else { + data[field.name] = null; + } + } + + if (contentType.name !== DEFAULT_CONTENT_TYPE_NAME) { + data['type'] = contentType.name; + } + + const content = ArticleHelper.stringifyFrontMatter(``, data); + + writeFileSync(newFilePath, content, { encoding: "utf8" }); + + const txtDoc = await workspace.openTextDocument(Uri.parse(newFilePath)); + if (txtDoc) { + window.showTextDocument(txtDoc); + } + + Notifications.info(`Your new content has been created.`); + } +} \ No newline at end of file diff --git a/src/helpers/Questions.ts b/src/helpers/Questions.ts new file mode 100644 index 00000000..51eb8bbd --- /dev/null +++ b/src/helpers/Questions.ts @@ -0,0 +1,84 @@ +import { window } from 'vscode'; +import { Folders } from '../commands/Folders'; +import { ContentType } from './ContentType'; +import { Notifications } from './Notifications'; + +export class Questions { + + /** + * Specify the name of the content to create + * @param showWarning + * @returns + */ + public static async ContentTitle(showWarning: boolean = true): Promise { + const title = await window.showInputBox({ + prompt: `What would you like to use as a title for the content to create?`, + placeHolder: `Content title` + }); + + if (!title && showWarning) { + Notifications.warning(`You did not specify a title for your content.`); + return; + } + + return title; + } + + /** + * Select the folder for your content creation + * @param showWarning + * @returns + */ + public static async SelectContentFolder(showWarning: boolean = true): Promise { + const folders = Folders.get(); + + let selectedFolder: string | undefined; + if (folders.length > 1) { + selectedFolder = await window.showQuickPick(folders.map(f => f.title), { + placeHolder: `Select where you want to create your content` + }); + } else { + selectedFolder = folders[0].title; + } + + if (!selectedFolder && showWarning) { + Notifications.warning(`You didn't select a place where you wanted to create your content.`); + return; + } + + return selectedFolder; + } + + /** + * Select the content type to create new content + * @param showWarning + * @returns + */ + public static async SelectContentType(showWarning: boolean = true): Promise { + const contentTypes = ContentType.getAll(); + if (!contentTypes || contentTypes.length === 0) { + Notifications.warning("No content types found. Please create a content type first."); + return; + } + + if (contentTypes.length === 1) { + return contentTypes[0].name; + } + + const options = contentTypes.map(contentType => ({ + label: contentType.name + })); + + const selectedOption = await window.showQuickPick(options, { + placeHolder: `Select the content type to create your new content`, + canPickMany: false + }); + + if (!selectedOption && showWarning) { + Notifications.warning("No content type was selected."); + return; + } + + return selectedOption?.label; + } +} \ No newline at end of file