Changes to the taxonomy dashboard

This commit is contained in:
Elio Struyf
2022-06-09 15:40:21 +02:00
parent 081fb7ce2e
commit 434e87b074
30 changed files with 894 additions and 146 deletions
-1
View File
@@ -259,7 +259,6 @@ export class Folders {
});
} catch (error) {
// Skip the file
console.log((error as Error).message)
}
}
+7 -73
View File
@@ -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.`);
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import * as React from 'react';
export interface IMergeIconProps {
className: string;
}
export const MergeIcon: React.FunctionComponent<IMergeIconProps> = ({className}: React.PropsWithChildren<IMergeIconProps>) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className={className}>
<path xmlns="http://www.w3.org/2000/svg" d="M7.586 8.00366L4 8.00366C3.44772 8.00366 3 7.55595 3 7.00366C3 6.45138 3.44772 6.00366 4 6.00366L8 6.00366C8.26509 6.00366 8.51933 6.10892 8.70685 6.2963L13.414 11H18.5845L15.2931 7.71103C14.9025 7.32065 14.9023 6.68748 15.2926 6.29681C15.683 5.90615 16.3162 5.90592 16.7068 6.2963L21.7068 11.2926C21.8945 11.4802 22 11.7346 22 11.9998C22 12.2651 21.8947 12.5195 21.7071 12.7071L16.7071 17.7071C16.3166 18.0976 15.6834 18.0976 15.2929 17.7071C14.9024 17.3166 14.9024 16.6834 15.2929 16.2929L18.5858 13H13.4142L8.70711 17.7071C8.51957 17.8947 8.26522 18 8 18H4C3.44772 18 3 17.5523 3 17C3 16.4477 3.44772 16 4 16H7.58579L11.5855 12.0003L7.586 8.00366Z" fill="currentcolor"/>
</svg>
);
};
+6
View File
@@ -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',
@@ -30,7 +30,13 @@ export const Item: React.FunctionComponent<IItemProps> = ({ 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) {
@@ -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<IDataViewProps> = (props: React.P
{
(dataFiles && dataFiles.length > 0) && (
dataFiles.map((dataFile, idx) => (
<button
<NavigationItem
key={`${dataFile.id}-${idx}`}
type='button'
className={`px-4 py-2 flex items-center text-sm font-medium w-full text-left hover:bg-gray-200 dark:hover:bg-vulcan-400 hover:text-vulcan-500 dark:hover:text-whisper-500 ${selectedData?.id === dataFile.id ? 'bg-gray-300 dark:bg-vulcan-300 text-vulcan-500 dark:text-whisper-500' : 'text-gray-500 dark:text-whisper-900'}`}
isSelected={selectedData?.id === dataFile.id}
onClick={() => setSchema(dataFile)}>
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
<span>{dataFile.title}</span>
</button>
</NavigationItem>
)
))
}
@@ -0,0 +1,28 @@
import * as React from 'react';
export interface INavigationBarProps {
title?: string;
bottom?: JSX.Element;
}
export const NavigationBar: React.FunctionComponent<INavigationBarProps> = ({title, bottom, children}: React.PropsWithChildren<INavigationBarProps>) => {
return (
<aside className={`w-2/12 px-4 py-6 h-full flex flex-col flex-grow border-r border-gray-200 dark:border-vulcan-300`}>
{
title && <h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>{title}</h2>
}
<nav className={`flex-1 py-4 -mx-4 h-full`}>
<div className={`divide-y divide-gray-200 dark:divide-vulcan-300 border-t border-b border-gray-200 dark:border-vulcan-300`}>
<div>
{children}
</div>
</div>
</nav>
{
bottom && bottom
}
</aside>
);
};
@@ -0,0 +1,17 @@
import * as React from 'react';
export interface INavigationItemProps {
isSelected?: boolean;
onClick?: () => void;
}
export const NavigationItem: React.FunctionComponent<INavigationItemProps> = ({isSelected, onClick, children}: React.PropsWithChildren<INavigationItemProps>) => {
return (
<button
type='button'
className={`px-4 py-2 flex items-center text-sm font-medium w-full text-left hover:bg-gray-200 dark:hover:bg-vulcan-400 hover:text-vulcan-500 dark:hover:text-whisper-500 cursor-pointer ${isSelected ? 'bg-gray-300 dark:bg-vulcan-300 text-vulcan-500 dark:text-whisper-500' : 'text-gray-500 dark:text-whisper-900'}`}
onClick={onClick}>
{children}
</button>
);
};
@@ -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<IPageLayoutProps> = ({ header, folders, totalPages, children }: React.PropsWithChildren<IPageLayoutProps>) => {
export const PageLayout: React.FunctionComponent<IPageLayoutProps> = ({ header, folders, totalPages, contentClass, children }: React.PropsWithChildren<IPageLayoutProps>) => {
const settings = useRecoilValue(SettingsSelector);
return (
@@ -20,7 +21,7 @@ export const PageLayout: React.FunctionComponent<IPageLayoutProps> = ({ header,
totalPages={totalPages}
settings={settings} />
<div className="w-full flex justify-between flex-col flex-grow max-w-7xl mx-auto pt-6 px-4">
<div className={contentClass || "w-full flex justify-between flex-col flex-grow max-w-7xl mx-auto pt-6 px-4"}>
{ children }
</div>
</div>
@@ -0,0 +1,3 @@
export * from './NavigationBar';
export * from './NavigationItem';
export * from './PageLayout';
@@ -83,7 +83,7 @@ export const Snippets: React.FunctionComponent<ISnippetsProps> = (props: React.P
</FeatureFlag>
)}>
<div className="flex flex-col">
<div className="flex flex-col h-full">
{
viewData?.data?.filePath && (
<div className={`text-xl text-center mb-6`}>
@@ -12,7 +12,7 @@ export interface ISponsorMsgProps {
export const SponsorMsg: React.FunctionComponent<ISponsorMsgProps> = ({beta, isBacker, version}: React.PropsWithChildren<ISponsorMsgProps>) => {
return (
<p className={`bg-gray-100 dark:bg-vulcan-500 w-full px-4 text-vulcan-50 dark:text-whisper-900 py-2 text-center space-x-8 flex items-center border-t border-gray-200 dark:border-vulcan-300 ${isBacker ? 'justify-center' : 'justify-between'}`}>
<footer className={`bg-gray-100 dark:bg-vulcan-500 w-full px-4 text-vulcan-50 dark:text-whisper-900 py-2 text-center space-x-8 flex items-center border-t border-gray-200 dark:border-vulcan-300 ${isBacker ? 'justify-center' : 'justify-between'}`}>
{
isBacker ? (
<span>Front Matter{version ? ` (v${version.installedVersion}${!!beta ? ` BETA` : ''})` : ''}</span>
@@ -28,6 +28,6 @@ export const SponsorMsg: React.FunctionComponent<ISponsorMsgProps> = ({beta, isB
</>
)
}
</p>
</footer>
);
};
@@ -33,6 +33,7 @@ const Folder = ({ wsFolder, folder, folders, addFolder }: { wsFolder: string, fo
export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps> = ({settings}: React.PropsWithChildren<IStepsToGetStartedProps>) => {
const [framework, setFramework] = useState<string | null>(null);
const [taxImported, setTaxImported] = useState<boolean>(false);
const frameworks: Framework[] = FrameworkDetectors.map((detector: any) => detector.framework);
@@ -55,6 +56,11 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
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<IStepsToGetStartedProps>
),
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.</>,
@@ -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<ITaxonomyActionsProps> = ({field, value, unmapped}: React.PropsWithChildren<ITaxonomyActionsProps>) => {
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 (
<div className={`space-x-2`}>
{
unmapped && (
<button
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-800 dark:hover:text-whisper-600'
type={`button`}
title={`Add ${value} to taxonomy settings`}
onClick={onAdd}>
<PlusIcon className={`w-4 h-4`} aria-hidden={true} />
<span className='sr-only'>Add to settings</span>
</button>
)
}
<button
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-800 dark:hover:text-whisper-600'
type={`button`}
title={`Edit ${value}`}
onClick={onEdit}>
<PencilIcon className={`w-4 h-4`} aria-hidden={true} />
<span className='sr-only'>Edit</span>
</button>
<button
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-800 dark:hover:text-whisper-600'
type={`button`}
title={`Merge ${value}`}
onClick={onMerge}>
<MergeIcon className={`w-4 h-4`} aria-hidden={true} />
<span className='sr-only'>Merge</span>
</button>
<button
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-800 dark:hover:text-whisper-600'
type={`button`}
title={`Delete ${value}`}
onClick={onDelete}>
<TrashIcon className={`w-4 h-4`} aria-hidden={true} />
<span className='sr-only'>Delete</span>
</button>
</div>
);
};
@@ -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<ITaxonomyLookupProps> = ({ taxonomy, value, pages }: React.PropsWithChildren<ITaxonomyLookupProps>) => {
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 (
<span className={``}>
{total}
</span>
);
};
@@ -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<ITaxonomyManagerProps> = ({ data, taxonomy, pages }: React.PropsWithChildren<ITaxonomyManagerProps>) => {
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 (
<div className={`py-6 px-4`}>
<div className={`flex w-full justify-between`}>
<div>
<h2 className={`text-lg text-gray-500 dark:text-whisper-900 first-letter:uppercase`}>{taxonomy}</h2>
<p className={`mt-2 text-sm text-gray-500 dark:text-whisper-900 first-letter:uppercase`}>Create, edit, and manage the {taxonomy} of your site</p>
</div>
<div>
<button
className={`inline-flex items-center px-3 py-1 border border-transparent text-xs leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-600 hover:bg-teal-700 focus:outline-none disabled:bg-gray-500`}
title={`Create a new ${taxonomy} value`}
onClick={onCreate}>
<PlusSmIcon className={`mr-2 h-6 w-6`} />
<span className={`text-sm`}>Create a new {taxonomy} value</span>
</button>
</div>
</div>
<div className="mt-6 flex flex-col">
<div className="-m-1.5 overflow-x-auto">
<div className="p-1.5 min-w-full inline-block align-middle">
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-vulcan-300">
<thead>
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Times used</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-vulcan-300">
{
items && items.length > 0 ?
items.map((item, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
<TagIcon className="inline-block h-4 w-4 mr-2" />
<span>{item}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
<TaxonomyLookup
taxonomy={taxonomy}
value={item}
pages={pages} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<TaxonomyActions
field={taxonomy}
value={item} />
</td>
</tr>
)) : (
!unmappedItems || unmappedItems.length === 0 && (
<tr>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200" colSpan={4}>No {taxonomy} found</td>
</tr>
)
)
}
{
unmappedItems && unmappedItems.length > 0 &&
unmappedItems.map((item, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200" title='Missing in your settings'>
<ExclamationIcon className="inline-block h-4 w-4 mr-2" />
<span>{item}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
<TaxonomyLookup
taxonomy={taxonomy}
value={item}
pages={pages} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<TaxonomyActions
field={taxonomy}
value={item}
unmapped />
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -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<ITaxonomyViewProps> = ({ pages }: React.PropsWithChildren<ITaxonomyViewProps>) => {
const messageListener = (message: MessageEvent<EventData<any>>) => {
const { command, data } = message.data;
const settings = useRecoilValue(SettingsSelector);
const [ taxonomySettings, setTaxonomySettings ] = useState<TaxonomyData>();
const [ selectedTaxonomy, setSelectedTaxonomy ] = useState<string | null>(`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 (
<PageLayout>
{pages.length}
<PageLayout
contentClass={`relative w-full flex-grow flex flex-col mx-auto overflow-hidden`}>
<div className={`h-full w-full flex`}>
<NavigationBar
title='Select the taxonomy'
bottom={(
<button
className={`-mb-4 text-xs opacity-80 flex items-center text-gray-500 dark:text-whisper-900 hover:text-gray-700 dark:hover:text-whisper-500`}
title="Import taxonomy"
onClick={onImport}>
<DownloadIcon className={`w-5 mr-2`} />
<span>Import taxonomy</span>
</button>
)}>
<NavigationItem
isSelected={selectedTaxonomy === "tags"}
onClick={() => setSelectedTaxonomy(`tags`)}>
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
<span>Tags</span>
</NavigationItem>
<NavigationItem
isSelected={selectedTaxonomy === "categories"}
onClick={() => setSelectedTaxonomy(`categories`)}>
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
<span>Categories</span>
</NavigationItem>
{
taxonomySettings?.customTaxonomy && taxonomySettings.customTaxonomy.map((taxonomy, index) => (
<NavigationItem
key={`${taxonomy.id}-${index}`}
isSelected={selectedTaxonomy === taxonomy.id}
onClick={() => setSelectedTaxonomy(taxonomy.id)}>
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
<span className={`first-letter:uppercase`}>{taxonomy.id}</span>
</NavigationItem>
))
}
</NavigationBar>
<div className={`w-10/12`}>
<TaxonomyManager
data={taxonomySettings}
taxonomy={selectedTaxonomy}
pages={pages} />
</div>
</div>
<SponsorMsg beta={settings?.beta} version={settings?.versionInfo} isBacker={settings?.isBacker} />
</PageLayout>
);
};
+1
View File
@@ -11,6 +11,7 @@ export interface Page {
fmPreviewImage: string;
fmTags: string[];
fmCategories: string[];
fmContentType: string;
title: string;
slug: string;
+2 -1
View File
@@ -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;
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export * from './getTaxonomyField';
+2 -1
View File
@@ -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<DashboardViewType | undefined>(ExtensionState.PagesView, "workspace"),
+19 -8
View File
@@ -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<Uri[] | null> {
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<Uri[] | null> {
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;
}
}
+30 -1
View File
@@ -219,10 +219,22 @@ export class Settings {
return [];
}
/**
* Return the taxonomy settings
*
* @param type
*/
public static getCustomTaxonomy(type: string): string[] {
const customTaxs = Settings.get<CustomTaxonomy[]>(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<CustomTaxonomy[]>(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
*/
+306
View File
@@ -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;
}
}
}
+1
View File
@@ -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';
+6 -1
View File
@@ -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) {
+21 -29
View File
@@ -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<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM) || []
};
const supportedFiles = Settings.get<string[]>(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);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { CustomTaxonomy } from ".";
export interface TaxonomyData {
tags: string[];
categories: string[];
customTaxonomy: CustomTaxonomy[];
}
+1
View File
@@ -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';