From 434e87b074b5febeb678cce7dab656ba2dedce52 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Thu, 9 Jun 2022 15:40:21 +0200 Subject: [PATCH] Changes to the taxonomy dashboard --- src/commands/Folders.ts | 1 - src/commands/Settings.ts | 80 +---- src/components/icons/MergeIcon.tsx | 13 + src/dashboardWebView/DashboardMessage.ts | 6 + .../components/Contents/Item.tsx | 8 +- .../components/DataView/DataView.tsx | 8 +- .../components/Layout/NavigationBar.tsx | 28 ++ .../components/Layout/NavigationItem.tsx | 17 + .../components/Layout/PageLayout.tsx | 9 +- .../components/Layout/index.ts | 3 + .../components/SnippetsView/Snippets.tsx | 2 +- .../components/SponsorMsg.tsx | 4 +- .../components/Steps/StepsToGetStarted.tsx | 12 + .../TaxonomyView/TaxonomyActions.tsx | 84 +++++ .../TaxonomyView/TaxonomyLookup.tsx | 40 +++ .../TaxonomyView/TaxonomyManager.tsx | 179 ++++++++++ .../components/TaxonomyView/TaxonomyView.tsx | 91 ++++-- src/dashboardWebView/models/Page.ts | 1 + src/dashboardWebView/models/Settings.ts | 3 +- .../utils/getTaxonomyField.ts | 16 + src/dashboardWebView/utils/index.ts | 1 + src/helpers/DashboardSettings.ts | 3 +- src/helpers/FilesHelper.ts | 27 +- src/helpers/SettingsHelper.ts | 31 +- src/helpers/TaxonomyHelper.ts | 306 ++++++++++++++++++ src/helpers/index.ts | 1 + src/listeners/dashboard/PagesListener.ts | 7 +- src/listeners/dashboard/TaxonomyListener.ts | 50 ++- src/models/TaxonomyData.ts | 8 + src/models/index.ts | 1 + 30 files changed, 894 insertions(+), 146 deletions(-) create mode 100644 src/components/icons/MergeIcon.tsx create mode 100644 src/dashboardWebView/components/Layout/NavigationBar.tsx create mode 100644 src/dashboardWebView/components/Layout/NavigationItem.tsx create mode 100644 src/dashboardWebView/components/Layout/index.ts create mode 100644 src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx create mode 100644 src/dashboardWebView/components/TaxonomyView/TaxonomyLookup.tsx create mode 100644 src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx create mode 100644 src/dashboardWebView/utils/getTaxonomyField.ts create mode 100644 src/dashboardWebView/utils/index.ts create mode 100644 src/helpers/TaxonomyHelper.ts create mode 100644 src/models/TaxonomyData.ts diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index d391468e..6380117b 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -259,7 +259,6 @@ export class Folders { }); } catch (error) { // Skip the file - console.log((error as Error).message) } } diff --git a/src/commands/Settings.ts b/src/commands/Settings.ts index 5a129441..89e93ac2 100644 --- a/src/commands/Settings.ts +++ b/src/commands/Settings.ts @@ -1,10 +1,9 @@ +import { TaxonomyHelper } from './../helpers/TaxonomyHelper'; import * as vscode from 'vscode'; -import * as fs from 'fs'; import { TaxonomyType } from "../models"; import { SETTING_TAXONOMY_TAGS, SETTING_TAXONOMY_CATEGORIES, EXTENSION_NAME } from '../constants'; import { ArticleHelper, Settings as SettingsHelper, FilesHelper } from '../helpers'; import { FrontMatterParser } from '../parsers'; -import { DumpOptions } from 'js-yaml'; import { Notifications } from '../helpers/Notifications'; export class Settings { @@ -76,7 +75,7 @@ export class Settings { */ public static async export() { // Retrieve all the Markdown files - const allMdFiles = await FilesHelper.getMdFiles(); + const allMdFiles = await FilesHelper.getAllFiles(); if (!allMdFiles) { return; } @@ -157,6 +156,7 @@ export class Settings { canPickMany: false, ignoreFocusOut: true }); + if (!taxType) { return; } @@ -196,76 +196,10 @@ export class Settings { } } - // Retrieve all the markdown files - const allMdFiles = await FilesHelper.getMdFiles(); - if (!allMdFiles) { - return; + if (newOptionValue) { + TaxonomyHelper.process("edit", type, selectedOption, newOptionValue); + } else { + TaxonomyHelper.process("delete", type, selectedOption, undefined); } - - let progressText = `${EXTENSION_NAME}: Remapping "${selectedOption}" ${type === TaxonomyType.Tag ? "tag" : "category"} to "${newOptionValue}".`; - if (!newOptionValue) { - progressText = `${EXTENSION_NAME}: Deleting "${selectedOption}" ${type === TaxonomyType.Tag ? "tag" : "category"}.`; - } - vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: progressText, - cancellable: false - }, async (progress) => { - // Set the initial progress - const progressNr = allMdFiles.length/100; - progress.report({ increment: 0}); - - const matterProp: string = type === TaxonomyType.Tag ? "tags" : "categories"; - - let i = 0; - for (const file of allMdFiles) { - progress.report({ increment: (++i/progressNr) }); - const mdFile = fs.readFileSync(file.path, { encoding: "utf8" }); - if (mdFile) { - try { - const article = FrontMatterParser.fromFile(mdFile); - if (article && article.data) { - const { data } = article; - let taxonomies: string[] = data[matterProp]; - if (taxonomies && taxonomies.length > 0) { - const idx = taxonomies.findIndex(o => o === selectedOption); - if (idx !== -1) { - if (newOptionValue) { - taxonomies[idx] = newOptionValue; - } else { - taxonomies = taxonomies.filter(o => o !== selectedOption); - } - data[matterProp] = [...new Set(taxonomies)].sort(); - const spaces = vscode.window.activeTextEditor?.options?.tabSize; - // Update the file - fs.writeFileSync(file.path, FrontMatterParser.toFile(article.content, article.data, mdFile, { - indent: spaces || 2 - } as DumpOptions as any), { encoding: "utf8" }); - } - } - } - } catch (e) { - // Continue with the next file - } - } - } - - // Update the settings - const idx = options.findIndex(o => o === selectedOption); - if (newOptionValue) { - // Add or update the new option - if (idx !== -1) { - options[idx] = newOptionValue; - } else { - options.push(newOptionValue); - } - } else { - // Remove the selected option - options = options.filter(o => o !== selectedOption); - } - await SettingsHelper.updateTaxonomy(type, options); - - Notifications.info(`${newOptionValue ? "Remapping" : "Deleation"} of the ${selectedOption} ${type === TaxonomyType.Tag ? "tag" : "category"} completed.`); - }); } } \ No newline at end of file diff --git a/src/components/icons/MergeIcon.tsx b/src/components/icons/MergeIcon.tsx new file mode 100644 index 00000000..2960543b --- /dev/null +++ b/src/components/icons/MergeIcon.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +export interface IMergeIconProps { + className: string; +} + +export const MergeIcon: React.FunctionComponent = ({className}: React.PropsWithChildren) => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index ccb64e9b..3d918cb1 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -43,6 +43,12 @@ export enum DashboardMessage { // Taxonomy dashboard getTaxonomyData = 'getTaxonomyData', + editTaxonomy = "editTaxonomy", + mergeTaxonomy = "mergeTaxonomy", + deleteTaxonomy = "deleteTaxonomy", + addToTaxonomy = "addToTaxonomy", + createTaxonomy = "createTaxonomy", + importTaxonomy = "importTaxonomy", // Other getTheme = 'getTheme', diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 447cf452..f6b24415 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -30,7 +30,13 @@ export const Item: React.FunctionComponent = ({ fmFilePath, date, ti } const tagField = settings.dashboardState.contents.tags; - return pageData[tagField] || []; + const tagsValue = pageData[tagField] || []; + + if (Array.isArray(tagsValue)) { + return tagsValue; + } + + return [tagsValue]; }, [settings, pageData]); if (view === DashboardViewType.Grid) { diff --git a/src/dashboardWebView/components/DataView/DataView.tsx b/src/dashboardWebView/components/DataView/DataView.tsx index 3d07f144..49ae0b30 100644 --- a/src/dashboardWebView/components/DataView/DataView.tsx +++ b/src/dashboardWebView/components/DataView/DataView.tsx @@ -20,6 +20,7 @@ import { ToastContainer, toast, Slide } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { DataType } from '../../../models/DataType'; import { TelemetryEvent } from '../../../constants'; +import { NavigationItem } from '../Layout'; export interface IDataViewProps {} @@ -150,14 +151,13 @@ export const DataView: React.FunctionComponent = (props: React.P { (dataFiles && dataFiles.length > 0) && ( dataFiles.map((dataFile, idx) => ( - + ) )) } diff --git a/src/dashboardWebView/components/Layout/NavigationBar.tsx b/src/dashboardWebView/components/Layout/NavigationBar.tsx new file mode 100644 index 00000000..88b197b1 --- /dev/null +++ b/src/dashboardWebView/components/Layout/NavigationBar.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +export interface INavigationBarProps { + title?: string; + bottom?: JSX.Element; +} + +export const NavigationBar: React.FunctionComponent = ({title, bottom, children}: React.PropsWithChildren) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Layout/NavigationItem.tsx b/src/dashboardWebView/components/Layout/NavigationItem.tsx new file mode 100644 index 00000000..43ca2f76 --- /dev/null +++ b/src/dashboardWebView/components/Layout/NavigationItem.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export interface INavigationItemProps { + isSelected?: boolean; + onClick?: () => void; +} + +export const NavigationItem: React.FunctionComponent = ({isSelected, onClick, children}: React.PropsWithChildren) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Layout/PageLayout.tsx b/src/dashboardWebView/components/Layout/PageLayout.tsx index 0efa4599..607a2737 100644 --- a/src/dashboardWebView/components/Layout/PageLayout.tsx +++ b/src/dashboardWebView/components/Layout/PageLayout.tsx @@ -5,11 +5,12 @@ import { Header } from '../Header'; export interface IPageLayoutProps { header?: React.ReactNode; - folders?: string[] | undefined - totalPages?: number | undefined + folders?: string[] | undefined; + totalPages?: number | undefined; + contentClass?: string; } -export const PageLayout: React.FunctionComponent = ({ header, folders, totalPages, children }: React.PropsWithChildren) => { +export const PageLayout: React.FunctionComponent = ({ header, folders, totalPages, contentClass, children }: React.PropsWithChildren) => { const settings = useRecoilValue(SettingsSelector); return ( @@ -20,7 +21,7 @@ export const PageLayout: React.FunctionComponent = ({ header, totalPages={totalPages} settings={settings} /> -
+
{ children }
diff --git a/src/dashboardWebView/components/Layout/index.ts b/src/dashboardWebView/components/Layout/index.ts new file mode 100644 index 00000000..607bfd07 --- /dev/null +++ b/src/dashboardWebView/components/Layout/index.ts @@ -0,0 +1,3 @@ +export * from './NavigationBar'; +export * from './NavigationItem'; +export * from './PageLayout'; diff --git a/src/dashboardWebView/components/SnippetsView/Snippets.tsx b/src/dashboardWebView/components/SnippetsView/Snippets.tsx index be570506..da35f135 100644 --- a/src/dashboardWebView/components/SnippetsView/Snippets.tsx +++ b/src/dashboardWebView/components/SnippetsView/Snippets.tsx @@ -83,7 +83,7 @@ export const Snippets: React.FunctionComponent = (props: React.P )}> -
+
{ viewData?.data?.filePath && (
diff --git a/src/dashboardWebView/components/SponsorMsg.tsx b/src/dashboardWebView/components/SponsorMsg.tsx index 1a56d1a5..374f9791 100644 --- a/src/dashboardWebView/components/SponsorMsg.tsx +++ b/src/dashboardWebView/components/SponsorMsg.tsx @@ -12,7 +12,7 @@ export interface ISponsorMsgProps { export const SponsorMsg: React.FunctionComponent = ({beta, isBacker, version}: React.PropsWithChildren) => { return ( -

+

{ isBacker ? ( Front Matter{version ? ` (v${version.installedVersion}${!!beta ? ` BETA` : ''})` : ''} @@ -28,6 +28,6 @@ export const SponsorMsg: React.FunctionComponent = ({beta, isB ) } -

+
); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/Steps/StepsToGetStarted.tsx b/src/dashboardWebView/components/Steps/StepsToGetStarted.tsx index 36f4b2f2..a5ea26a1 100644 --- a/src/dashboardWebView/components/Steps/StepsToGetStarted.tsx +++ b/src/dashboardWebView/components/Steps/StepsToGetStarted.tsx @@ -33,6 +33,7 @@ const Folder = ({ wsFolder, folder, folders, addFolder }: { wsFolder: string, fo export const StepsToGetStarted: React.FunctionComponent = ({settings}: React.PropsWithChildren) => { const [framework, setFramework] = useState(null); + const [taxImported, setTaxImported] = useState(false); const frameworks: Framework[] = FrameworkDetectors.map((detector: any) => detector.framework); @@ -55,6 +56,11 @@ export const StepsToGetStarted: React.FunctionComponent Messenger.send(DashboardMessage.reload); }; + const importTaxonomy = () => { + Messenger.send(DashboardMessage.importTaxonomy); + setTaxImported(true); + } + const steps = [ { name: 'Initialize project', @@ -136,6 +142,12 @@ export const StepsToGetStarted: React.FunctionComponent ), status: settings.contentFolders && settings.contentFolders.length > 0 ? Status.Completed : Status.NotStarted }, + { + name: 'Import all tags and categories (optional)', + description: <>Now that Front Matter knows all the content folders. Would you like to import all tags and categories from the available content?, + status: taxImported ? Status.Completed : Status.NotStarted, + onClick: settings.contentFolders && settings.contentFolders.length > 0 ? importTaxonomy : undefined + }, { name: 'Show the dashboard', description: <>Once all actions are completed, the dashboard can be loaded., diff --git a/src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx b/src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx new file mode 100644 index 00000000..0194d71e --- /dev/null +++ b/src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx @@ -0,0 +1,84 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/outline'; +import * as React from 'react'; +import { useCallback } from 'react'; +import { MergeIcon } from '../../../components/icons/MergeIcon'; +import { DashboardMessage } from '../../DashboardMessage'; + +export interface ITaxonomyActionsProps { + field: string | null; + value: string; + unmapped?: boolean; +} + +export const TaxonomyActions: React.FunctionComponent = ({field, value, unmapped}: React.PropsWithChildren) => { + + const onEdit = useCallback(() => { + Messenger.send(DashboardMessage.editTaxonomy, { + type: field, + value + }); + }, [field, value]); + + const onAdd = useCallback(() => { + Messenger.send(DashboardMessage.addToTaxonomy, { + type: field, + value + }); + }, [field, value]); + + const onMerge = useCallback(() => { + Messenger.send(DashboardMessage.mergeTaxonomy, { + type: field, + value + }); + }, [field, value]); + + const onDelete = useCallback(() => { + Messenger.send(DashboardMessage.deleteTaxonomy, { + type: field, + value + }); + }, [field, value]); + + return ( +
+ { + unmapped && ( + + ) + } + + + +
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/TaxonomyView/TaxonomyLookup.tsx b/src/dashboardWebView/components/TaxonomyView/TaxonomyLookup.tsx new file mode 100644 index 00000000..a0bbfdad --- /dev/null +++ b/src/dashboardWebView/components/TaxonomyView/TaxonomyLookup.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import { Page } from '../../models'; +import { SettingsSelector } from '../../state'; +import { useRecoilValue } from 'recoil'; +import { getTaxonomyField } from '../../utils'; + +export interface ITaxonomyLookupProps { + taxonomy: string | null; + value: string; + pages: Page[]; +} + +export const TaxonomyLookup: React.FunctionComponent = ({ taxonomy, value, pages }: React.PropsWithChildren) => { + const settings = useRecoilValue(SettingsSelector); + + const total = useMemo(() => { + if (!taxonomy || !value || !pages || !settings?.contentTypes) { + return 0; + } + + return pages.filter(page => { + const contentType = settings.contentTypes.find(ct => ct.name === page.fmContentType); + + if (!contentType) { + return false; + } + + let fieldName = getTaxonomyField(taxonomy, contentType); + + return fieldName && page[fieldName] ? page[fieldName].includes(value) : false; + }).length; + }, [taxonomy, value, pages, settings?.contentTypes]); + + return ( + + {total} + + ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx b/src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx new file mode 100644 index 00000000..72ea4c17 --- /dev/null +++ b/src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx @@ -0,0 +1,179 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import { ExclamationIcon, PlusSmIcon, TagIcon } from '@heroicons/react/outline'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { TaxonomyData } from '../../../models'; +import { DashboardMessage } from '../../DashboardMessage'; +import { Page } from '../../models'; +import { SettingsSelector } from '../../state'; +import { getTaxonomyField } from '../../utils'; +import { TaxonomyActions } from './TaxonomyActions'; +import { TaxonomyLookup } from './TaxonomyLookup'; + +export interface ITaxonomyManagerProps { + data: TaxonomyData | undefined; + taxonomy: string | null; + pages: Page[]; +} + +export const TaxonomyManager: React.FunctionComponent = ({ data, taxonomy, pages }: React.PropsWithChildren) => { + const settings = useRecoilValue(SettingsSelector); + + const onCreate = () => { + Messenger.send(DashboardMessage.createTaxonomy, { + type: taxonomy + }); + }; + + const items = useMemo(() => { + if (data && taxonomy) { + let crntItems: string[] = []; + + if (taxonomy === "tags" || taxonomy === "categories") { + crntItems = data[taxonomy]; + } else { + crntItems = data.customTaxonomy.find(c => c.id === taxonomy)?.options || []; + } + + // Alphabetically sort the items + crntItems = Object.assign([], crntItems).sort((a: string, b: string) => { + if (a.toLowerCase() < b.toLowerCase()) { + return -1; + } + + if (a.toLowerCase() > b.toLowerCase()) { + return 1; + } + + return 0; + }); + + return crntItems; + } + + return []; + }, [data, taxonomy]); + + const unmappedItems = useMemo(() => { + let unmapped: string[] = []; + + if (!pages || !settings?.contentTypes || !taxonomy) { + return unmapped; + } + + for (const page of pages) { + const contentType = settings.contentTypes.find(ct => ct.name === page.fmContentType); + + if (!contentType) { + return false; + } + + let fieldName = getTaxonomyField(taxonomy, contentType); + + if (fieldName && page[fieldName]) { + const values = page[fieldName]; + + for (const value of values) { + if (!items.includes(value)) { + unmapped.push(value); + } + } + } + } + + return [...new Set(unmapped)]; + }, [items, taxonomy, pages, settings?.contentTypes]); + + return ( +
+
+
+

{taxonomy}

+

Create, edit, and manage the {taxonomy} of your site

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + { + items && items.length > 0 ? + items.map((item, index) => ( + + + + + + )) : ( + !unmappedItems || unmappedItems.length === 0 && ( + + + + ) + ) + } + + { + unmappedItems && unmappedItems.length > 0 && + unmappedItems.map((item, index) => ( + + + + + + )) + } + +
NameTimes usedAction
+ + {item} + + + + +
No {taxonomy} found
+ + {item} + + + + +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/TaxonomyView/TaxonomyView.tsx b/src/dashboardWebView/components/TaxonomyView/TaxonomyView.tsx index f238a8ce..eb111ee3 100644 --- a/src/dashboardWebView/components/TaxonomyView/TaxonomyView.tsx +++ b/src/dashboardWebView/components/TaxonomyView/TaxonomyView.tsx @@ -1,44 +1,97 @@ -import { EventData } from '@estruyf/vscode'; import { Messenger } from '@estruyf/vscode/dist/client'; +import { ChevronRightIcon, DownloadIcon } from '@heroicons/react/outline'; import * as React from 'react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { TelemetryEvent } from '../../../constants'; -import { DashboardCommand } from '../../DashboardCommand'; +import { TaxonomyData } from '../../../models'; import { DashboardMessage } from '../../DashboardMessage'; import { Page } from '../../models'; +import { SettingsSelector } from '../../state'; +import { NavigationBar, NavigationItem } from '../Layout'; import { PageLayout } from '../Layout/PageLayout'; +import { SponsorMsg } from '../SponsorMsg'; +import { TaxonomyManager } from './TaxonomyManager'; export interface ITaxonomyViewProps { pages: Page[]; } export const TaxonomyView: React.FunctionComponent = ({ pages }: React.PropsWithChildren) => { - - const messageListener = (message: MessageEvent>) => { - const { command, data } = message.data; + const settings = useRecoilValue(SettingsSelector); + const [ taxonomySettings, setTaxonomySettings ] = useState(); + const [ selectedTaxonomy, setSelectedTaxonomy ] = useState(`tags`); - if (command === DashboardCommand.setTaxonomyData) { - console.log('TaxonomyView: setTaxonomyData', data); - } + const onImport = () => { + Messenger.send(DashboardMessage.importTaxonomy); }; + useEffect(() => { + setTaxonomySettings({ + tags: settings?.tags || [], + categories: settings?.categories || [], + customTaxonomy: settings?.customTaxonomy || [], + }); + }, [settings?.tags, settings?.categories, settings?.customTaxonomy]); + useEffect(() => { Messenger.send(DashboardMessage.sendTelemetry, { event: TelemetryEvent.webviewTaxonomyDashboard }); - - Messenger.send(DashboardMessage.getTaxonomyData); - - Messenger.listen(messageListener); - - return () => { - Messenger.unlisten(messageListener); - } }, []); return ( - - {pages.length} + + +
+ + + Import taxonomy + + )}> + setSelectedTaxonomy(`tags`)}> + + Tags + + + setSelectedTaxonomy(`categories`)}> + + Categories + + + { + taxonomySettings?.customTaxonomy && taxonomySettings.customTaxonomy.map((taxonomy, index) => ( + setSelectedTaxonomy(taxonomy.id)}> + + {taxonomy.id} + + )) + } + + +
+ +
+
+ +
); }; \ No newline at end of file diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index 3237e9d1..3d4dc1d7 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -11,6 +11,7 @@ export interface Page { fmPreviewImage: string; fmTags: string[]; fmCategories: string[]; + fmContentType: string; title: string; slug: string; diff --git a/src/dashboardWebView/models/Settings.ts b/src/dashboardWebView/models/Settings.ts index 558daa9a..f03e4e8e 100644 --- a/src/dashboardWebView/models/Settings.ts +++ b/src/dashboardWebView/models/Settings.ts @@ -1,7 +1,7 @@ import { DataType } from './../../models/DataType'; import { VersionInfo } from '../../models/VersionInfo'; import { ContentFolder } from '../../models/ContentFolder'; -import { ContentType, CustomScript, DraftField, Framework, Snippets, SortingSetting } from '../../models'; +import { ContentType, CustomScript, CustomTaxonomy, DraftField, Framework, Snippets, SortingSetting } from '../../models'; import { SortingOption } from './SortingOption'; import { DashboardViewType } from '.'; import { DataFile } from '../../models/DataFile'; @@ -13,6 +13,7 @@ export interface Settings { staticFolder: string; tags: string[]; categories: string[]; + customTaxonomy: CustomTaxonomy[]; openOnStart: boolean | null; versionInfo: VersionInfo; pageViewType: DashboardViewType | undefined; diff --git a/src/dashboardWebView/utils/getTaxonomyField.ts b/src/dashboardWebView/utils/getTaxonomyField.ts new file mode 100644 index 00000000..65fe10f3 --- /dev/null +++ b/src/dashboardWebView/utils/getTaxonomyField.ts @@ -0,0 +1,16 @@ +import { ContentType } from '../../models'; + + +export const getTaxonomyField = (taxonomyType: string, contentType: ContentType): string | undefined => { + let fieldName: string | undefined; + + if (taxonomyType === "tags") { + fieldName = contentType.fields.find(f => f.name === "tags")?.name || "tags"; + } else if (taxonomyType === "categories") { + fieldName = contentType.fields.find(f => f.name === "categories")?.name || "categories"; + } else { + fieldName = contentType.fields.find(f => f.type === "taxonomy" && f.taxonomyId === taxonomyType)?.name; + } + + return fieldName; +} \ No newline at end of file diff --git a/src/dashboardWebView/utils/index.ts b/src/dashboardWebView/utils/index.ts new file mode 100644 index 00000000..9d83d77a --- /dev/null +++ b/src/dashboardWebView/utils/index.ts @@ -0,0 +1 @@ +export * from './getTaxonomyField'; diff --git a/src/helpers/DashboardSettings.ts b/src/helpers/DashboardSettings.ts index 48d9e8fa..f6969beb 100644 --- a/src/helpers/DashboardSettings.ts +++ b/src/helpers/DashboardSettings.ts @@ -3,7 +3,7 @@ import { workspace } from "vscode"; import { Folders } from "../commands/Folders"; import { Project } from "../commands/Project"; import { Template } from "../commands/Template"; -import { CONTEXT, ExtensionState, SETTING_CONTENT_DRAFT_FIELD, SETTING_CONTENT_SORTING, SETTING_CONTENT_SORTING_DEFAULT, SETTING_CONTENT_STATIC_FOLDER, SETTING_DASHBOARD_OPENONSTART, SETTING_DATA_FILES, SETTING_DATA_FOLDERS, SETTING_DATA_TYPES, SETTING_FRAMEWORK_ID, SETTING_MEDIA_SORTING_DEFAULT, SETTING_CUSTOM_SCRIPTS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_CONTENT_SNIPPETS, SETTING_DATE_FORMAT, SETTING_DASHBOARD_CONTENT_TAGS, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants"; +import { CONTEXT, ExtensionState, SETTING_CONTENT_DRAFT_FIELD, SETTING_CONTENT_SORTING, SETTING_CONTENT_SORTING_DEFAULT, SETTING_CONTENT_STATIC_FOLDER, SETTING_DASHBOARD_OPENONSTART, SETTING_DATA_FILES, SETTING_DATA_FOLDERS, SETTING_DATA_TYPES, SETTING_FRAMEWORK_ID, SETTING_MEDIA_SORTING_DEFAULT, SETTING_CUSTOM_SCRIPTS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_CONTENT_SNIPPETS, SETTING_DATE_FORMAT, SETTING_DASHBOARD_CONTENT_TAGS, SETTING_MEDIA_SUPPORTED_MIMETYPES, SETTING_TAXONOMY_CUSTOM } from "../constants"; import { DashboardViewType, SortingOption, Settings as ISettings } from "../dashboardWebView/models"; import { CustomScript, DraftField, Snippets, SortingSetting, TaxonomyType } from "../models"; import { DataFile } from "../models/DataFile"; @@ -28,6 +28,7 @@ export class DashboardSettings { initialized: isInitialized, tags: Settings.getTaxonomy(TaxonomyType.Tag), categories: Settings.getTaxonomy(TaxonomyType.Category), + customTaxonomy: Settings.get(SETTING_TAXONOMY_CUSTOM, true) || [], openOnStart: Settings.get(SETTING_DASHBOARD_OPENONSTART), versionInfo: ext.getVersion(), pageViewType: await ext.getState(ExtensionState.PagesView, "workspace"), diff --git a/src/helpers/FilesHelper.ts b/src/helpers/FilesHelper.ts index 8bd39700..e15a4b73 100644 --- a/src/helpers/FilesHelper.ts +++ b/src/helpers/FilesHelper.ts @@ -1,21 +1,32 @@ import { Notifications } from './Notifications'; import { Uri, workspace } from 'vscode'; +import { Folders } from '../commands/Folders'; +import { isValidFile } from './isValidFile'; export class FilesHelper { /** * Retrieve all markdown files from the current project */ - public static async getMdFiles(): Promise { - const mdFiles = await workspace.findFiles('**/*.md', "**/node_modules/**,**/archetypes/**"); - const markdownFiles = await workspace.findFiles('**/*.markdown', "**/node_modules/**,**/archetypes/**"); - const mdxFiles = await workspace.findFiles('**/*.mdx', "**/node_modules/**,**/archetypes/**"); - if (!mdFiles && !markdownFiles) { - Notifications.info(`No MD files found.`); + public static async getAllFiles(): Promise { + const folderInfo = await Folders.getInfo(); + const pages: Uri[] = []; + + if (folderInfo) { + for (const folder of folderInfo) { + for (const file of folder.lastModified) { + if (isValidFile(file.fileName)) { + pages.push(Uri.file(file.filePath)); + } + } + } + } + + if (pages.length === 0) { + Notifications.warning(`No files found.`); return null; } - const allMdFiles = [...mdFiles, ...markdownFiles, ...mdxFiles]; - return allMdFiles; + return pages; } } \ No newline at end of file diff --git a/src/helpers/SettingsHelper.ts b/src/helpers/SettingsHelper.ts index e81f82b7..251a5974 100644 --- a/src/helpers/SettingsHelper.ts +++ b/src/helpers/SettingsHelper.ts @@ -219,10 +219,22 @@ export class Settings { return []; } + /** + * Return the taxonomy settings + * + * @param type + */ + public static getCustomTaxonomy(type: string): string[] { + const customTaxs = Settings.get(SETTING_TAXONOMY_CUSTOM, true); + if (customTaxs && customTaxs.length > 0) { + return customTaxs.find(t => t.id === type)?.options || []; + } + return []; + } + /** * Update the taxonomy settings * - * @param config * @param type * @param options */ @@ -259,6 +271,23 @@ export class Settings { await Settings.update(SETTING_TAXONOMY_CUSTOM, customTaxonomies, true); } + /** + * Update the taxonomy settings + * + * @param type + * @param options + */ + public static async updateCustomTaxonomyOptions(id: string, options: string[]) { + const customTaxonomies = Settings.get(SETTING_TAXONOMY_CUSTOM, true) || []; + let taxIdx = customTaxonomies?.findIndex(o => o.id === id); + + if (taxIdx !== -1) { + customTaxonomies[taxIdx].options = options; + } + + await Settings.update(SETTING_TAXONOMY_CUSTOM, customTaxonomies, true); + } + /** * Promote settings from local to team level */ diff --git a/src/helpers/TaxonomyHelper.ts b/src/helpers/TaxonomyHelper.ts new file mode 100644 index 00000000..6d408bcf --- /dev/null +++ b/src/helpers/TaxonomyHelper.ts @@ -0,0 +1,306 @@ +import { EXTENSION_NAME } from "../constants"; +import { TaxonomyType } from "../models"; +import { FilesHelper } from "./FilesHelper"; +import { ProgressLocation, window } from "vscode"; +import { parseWinPath } from "./parseWinPath"; +import { readFileSync, writeFileSync } from "fs"; +import { FrontMatterParser } from "../parsers"; +import { DumpOptions } from "js-yaml"; +import { Settings } from "./SettingsHelper"; +import { Notifications } from "./Notifications"; +import { ArticleHelper } from './ArticleHelper'; + + +export class TaxonomyHelper { + + /** + * Rename an taxonomy value + * @param data + * @returns + */ + public static async rename(data: { type: string, value: string }) { + const { type, value } = data; + + const answer = await window.showInputBox({ + title: `Rename the "${value}"`, + value, + validateInput: (text) => { + if (text === value) { + return "The new value must be different from the old one."; + } + + if (!text) { + return "A new value must be provided."; + } + + return null; + }, + ignoreFocusOut: true + }); + + if (!answer) { + return; + } + + this.process("edit", this.getTypeFromString(type), value, answer); + } + + /** + * Merge a taxonomy value with another one + * @param data + * @returns + */ + public static async merge(data: { type: string, value: string }) { + const { type, value } = data; + const taxonomyType = this.getTypeFromString(type); + + let options = []; + if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) { + options = Settings.getTaxonomy(taxonomyType); + } else { + options = Settings.getCustomTaxonomy(taxonomyType); + } + + const answer = await window.showQuickPick(options.filter(o => o !== value), { + title: `Merge the "${value}" with another ${type} value`, + placeHolder: `Select the ${type} value to merge with`, + ignoreFocusOut: true + }); + + if (!answer) { + return; + } + + this.process("merge", taxonomyType, value, answer); + } + + /** + * Delete a taxonomy value + * @param data + */ + public static async delete(data: { type: string, value: string }) { + const { type, value } = data; + + const answer = await window.showQuickPick(["Yes", "No"], { + title: `Delete the "${value}" ${type} value`, + placeHolder: `Are you sure you want to delete the "${value}" ${type} value?`, + ignoreFocusOut: true + }); + + if (!answer || answer === "No") { + return; + } + + this.process("delete", this.getTypeFromString(type), value, undefined); + } + + /** + * Add the taxonomy value to the settings + * @param data + */ + public static addTaxonomy(data: { type: string, value: string }) { + const { type, value } = data; + this.addToSettings(this.getTypeFromString(type), value, value); + } + + /** + * Create new taxonomy value + * @param data + */ + public static async createNew(data: { type: string }) { + const { type } = data; + + const taxonomyType = this.getTypeFromString(type); + const options = this.getTaxonomyOptions(taxonomyType); + + const newOption = await window.showInputBox({ + title: `Create a new ${taxonomyType} value`, + placeHolder: `The value you want to add`, + ignoreFocusOut: true, + validateInput: (text) => { + if (!text) { + return "A value must be provided."; + } + + if (options.includes(text)) { + return "The value already exists."; + } + + return null; + } + }); + + if (!newOption) { + return; + } + + this.addToSettings(taxonomyType, newOption, newOption); + } + + /** + * Process the taxonomy changes + * @param type + * @param taxonomyType + * @param oldValue + * @param newValue + * @returns + */ + public static async process(type: "edit" | "merge" | "delete", taxonomyType: TaxonomyType | string, oldValue: string, newValue?: string) { + // Retrieve all the markdown files + const allFiles = await FilesHelper.getAllFiles(); + if (!allFiles) { + return; + } + + let taxonomyName: string; + if (taxonomyType === TaxonomyType.Tag) { + taxonomyName = "tags"; + } else if (taxonomyType === TaxonomyType.Category) { + taxonomyName = "categories"; + } else { + taxonomyName = taxonomyType; + } + + let progressText = ``; + + if (type === "edit") { + progressText = `${EXTENSION_NAME}: Renaming "${oldValue}" from ${taxonomyName} to "${newValue}".`; + } else if (type === "merge") { + progressText = `${EXTENSION_NAME}: Merging "${oldValue}" from "${taxonomyName}" to "${newValue}".`; + } else if (type === "delete") { + progressText = `${EXTENSION_NAME}: Deleting "${oldValue}" from "${taxonomyName}".`; + } + + window.withProgress({ + location: ProgressLocation.Notification, + title: progressText, + cancellable: false + }, async (progress) => { + // Set the initial progress + const progressNr = allFiles.length/100; + progress.report({ increment: 0}); + + let i = 0; + for (const file of allFiles) { + progress.report({ increment: (++i/progressNr) }); + + const mdFile = readFileSync(parseWinPath(file.fsPath), { encoding: "utf8" }); + + if (mdFile) { + try { + const article = FrontMatterParser.fromFile(mdFile); + const contentType = ArticleHelper.getContentType(article.data); + let fieldName: string | undefined; + + if (taxonomyName === "tags") { + fieldName = contentType.fields.find(f => f.type === "tags")?.name || "tags"; + } else if (taxonomyName === "categories") { + fieldName = contentType.fields.find(f => f.type === "categories")?.name || "categories"; + } else { + fieldName = contentType.fields.find(f => f.type === "taxonomy" && f.taxonomyId === taxonomyName)?.name; + } + + if (fieldName && article && article.data) { + const { data } = article; + let taxonomies: string[] = data[fieldName]; + + if (taxonomies && taxonomies.length > 0) { + const idx = taxonomies.findIndex(o => o === oldValue); + + if (idx !== -1) { + if (newValue) { + taxonomies[idx] = newValue; + } else { + taxonomies = taxonomies.filter(o => o !== oldValue); + } + + data[fieldName] = [...new Set(taxonomies)].sort(); + + const spaces = window.activeTextEditor?.options?.tabSize; + // Update the file + writeFileSync(parseWinPath(file.fsPath), FrontMatterParser.toFile(article.content, article.data, mdFile, { + indent: spaces || 2 + } as DumpOptions as any), { encoding: "utf8" }); + } + } + } + } catch (e) { + // Continue with the next file + } + } + } + + await this.addToSettings(taxonomyType, oldValue, newValue); + + if (type === "edit") { + Notifications.info(`Edit completed.`); + } else if (type === "merge") { + Notifications.info(`Merge completed.`); + } else if (type === "delete") { + Notifications.info(`Deletion completed.`); + } + }); + } + + /** + * Add the taxonomy value to the settings + * @param taxonomyType + * @param oldValue + * @param newValue + */ + private static async addToSettings(taxonomyType: TaxonomyType | string, oldValue: string, newValue?: string) { + // Update the settings + let options = this.getTaxonomyOptions(taxonomyType); + + const idx = options.findIndex(o => o === oldValue); + if (newValue) { + // Add or update the new option + if (idx !== -1) { + options[idx] = newValue; + } else { + options.push(newValue); + } + } else { + // Remove the selected option + options = options.filter(o => o !== oldValue); + } + + if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) { + await Settings.updateTaxonomy(taxonomyType, options); + } else { + await Settings.updateCustomTaxonomyOptions(taxonomyType, options); + } + } + + /** + * Get the taxonomy options + * @param taxonomyType + * @returns + */ + private static getTaxonomyOptions(taxonomyType: TaxonomyType | string) { + let options = []; + + if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) { + options = Settings.getTaxonomy(taxonomyType); + } else { + options = Settings.getCustomTaxonomy(taxonomyType); + } + + return options; + } + + /** + * Retrieve the taxonomy type based from the string + * @param taxonomyType + * @returns + */ + private static getTypeFromString(taxonomyType: string): TaxonomyType | string { + if (taxonomyType === "tags") { + return TaxonomyType.Tag; + } else if (taxonomyType === "categories") { + return TaxonomyType.Category; + } else { + return taxonomyType; + } + } +} \ No newline at end of file diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 5988e1bd..7e48629f 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -23,6 +23,7 @@ export * from './SlugHelper'; export * from './SnippetParser'; export * from './Sorting'; export * from './StringHelpers'; +export * from './TaxonomyHelper'; export * from './Telemetry'; export * from './decodeBase64Image'; export * from './getNonce'; diff --git a/src/listeners/dashboard/PagesListener.ts b/src/listeners/dashboard/PagesListener.ts index 42994f99..c401e9c2 100644 --- a/src/listeners/dashboard/PagesListener.ts +++ b/src/listeners/dashboard/PagesListener.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CONTENT_TYPE_NAME } from './../../constants/ContentType'; import { isValidFile } from '../../helpers/isValidFile'; import { existsSync, unlinkSync } from "fs"; import { basename, dirname, join } from "path"; @@ -91,7 +92,7 @@ export class PagesListener extends BaseListener { // Recreate all the watchers for (const folder of folders) { const folderUri = Uri.parse(folder.path); - let watcher = workspace.createFileSystemWatcher(new RelativePattern(folderUri, "*"), false, false, false); + let watcher = workspace.createFileSystemWatcher(new RelativePattern(folderUri, "**/*"), false, false, false); watcher.onDidCreate(async (uri: Uri) => this.watcherExec(uri)); watcher.onDidChange(async (uri: Uri) => this.watcherExec(uri)); watcher.onDidDelete(async (uri: Uri) => this.watcherExec(uri)); @@ -282,6 +283,7 @@ export class PagesListener extends BaseListener { fmPreviewImage: "", fmTags: [], fmCategories: [], + fmContentType: DEFAULT_CONTENT_TYPE_NAME, fmBody: article?.content || "", // Make sure these are always set title: article?.data.title, @@ -292,6 +294,9 @@ export class PagesListener extends BaseListener { }; const contentType = ArticleHelper.getContentType(article.data); + if (contentType) { + page.fmContentType = contentType.name; + } let previewFieldParents = this.findPreviewField(contentType.fields); if (previewFieldParents.length === 0) { diff --git a/src/listeners/dashboard/TaxonomyListener.ts b/src/listeners/dashboard/TaxonomyListener.ts index 5e502d63..71b712c4 100644 --- a/src/listeners/dashboard/TaxonomyListener.ts +++ b/src/listeners/dashboard/TaxonomyListener.ts @@ -1,10 +1,8 @@ -import { join } from "path"; -import { Uri, workspace } from "vscode"; -import { Folders } from "../../commands/Folders"; -import { DEFAULT_FILE_TYPES, SETTING_CONTENT_SUPPORTED_FILETYPES, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_CUSTOM, SETTING_TAXONOMY_TAGS } from "../../constants"; +import { commands } from "vscode"; +import { COMMAND_NAME, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_CUSTOM, SETTING_TAXONOMY_TAGS } from "../../constants"; import { DashboardCommand } from "../../dashboardWebView/DashboardCommand"; import { DashboardMessage } from "../../dashboardWebView/DashboardMessage"; -import { Settings } from "../../helpers"; +import { Settings, TaxonomyHelper } from "../../helpers"; import { CustomTaxonomy } from "../../models"; import { BaseListener } from "./BaseListener"; @@ -22,6 +20,24 @@ export class TaxonomyListener extends BaseListener { case DashboardMessage.getTaxonomyData: this.getData(); break; + case DashboardMessage.editTaxonomy: + TaxonomyHelper.rename(msg.data); + break; + case DashboardMessage.mergeTaxonomy: + TaxonomyHelper.merge(msg.data); + break; + case DashboardMessage.deleteTaxonomy: + TaxonomyHelper.delete(msg.data); + break; + case DashboardMessage.addToTaxonomy: + TaxonomyHelper.addTaxonomy(msg.data); + break; + case DashboardMessage.createTaxonomy: + TaxonomyHelper.createNew(msg.data); + break; + case DashboardMessage.importTaxonomy: + commands.executeCommand(COMMAND_NAME.exportTaxonomy); + break; } } @@ -33,30 +49,6 @@ export class TaxonomyListener extends BaseListener { customTaxonomy: Settings.get(SETTING_TAXONOMY_CUSTOM) || [] }; - const supportedFiles = Settings.get(SETTING_CONTENT_SUPPORTED_FILETYPES) || DEFAULT_FILE_TYPES; - const fileExtensions = supportedFiles.map(fileType => `${fileType.startsWith('.') ? '' : '.'}${fileType}`); - - const folders = Folders.get(); - const projectName = Folders.getProjectFolderName(); - - let files: Uri[] = []; - - for (const folder of folders) { - let projectStart = folder.path.split(projectName).pop(); - projectStart = projectStart || ""; - projectStart = projectStart?.replace(/\\/g, '/'); - projectStart = projectStart?.startsWith('/') ? projectStart.substring(1) : projectStart; - - for (const fileExtension of fileExtensions) { - const crntFiles = await workspace.findFiles(join(projectStart, folder.excludeSubdir ? '/' : '**/', `*${fileExtension}`)); - if (crntFiles && crntFiles.length > 0) { - files = [...files, ...crntFiles]; - } - } - } - - console.log(files) - this.sendMsg(DashboardCommand.setTaxonomyData, taxonomyData); } } \ No newline at end of file diff --git a/src/models/TaxonomyData.ts b/src/models/TaxonomyData.ts new file mode 100644 index 00000000..7f4aa8ed --- /dev/null +++ b/src/models/TaxonomyData.ts @@ -0,0 +1,8 @@ +import { CustomTaxonomy } from "."; + + +export interface TaxonomyData { + tags: string[]; + categories: string[]; + customTaxonomy: CustomTaxonomy[]; +} \ No newline at end of file diff --git a/src/models/index.ts b/src/models/index.ts index a71d4263..65b60c25 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -15,5 +15,6 @@ export * from './Snippets'; export * from './SortOrder'; export * from './SortType'; export * from './SortingSetting'; +export * from './TaxonomyData'; export * from './TaxonomyType'; export * from './VersionInfo';