Compare commits

...

44 Commits

Author SHA1 Message Date
Elio Struyf
43ae9a6ba2 Merge pull request #367 from estruyf/dev 2022-07-11 16:09:40 +02:00
Elio Struyf
c24cc2165f Changelog update 2022-07-11 16:03:03 +02:00
Elio Struyf
f177a61d4f Hide template button if disabled 2022-07-11 15:09:47 +02:00
Elio Struyf
f13b9e8ea5 #364 - Check content if ends with newline 2022-07-11 15:09:18 +02:00
Elio Struyf
c04dd79778 Test updates 2022-06-30 17:39:10 +02:00
Elio Struyf
00273a8c86 #366 - Better support for block in block fields 2022-06-29 09:57:32 +02:00
Elio Struyf
231ef804dc Added sponsor info 2022-06-28 17:13:11 +02:00
Elio Struyf
44dc22c792 #365 - FIx for the spinner 2022-06-28 15:17:32 +02:00
Elio Struyf
830fc550bd Ignore keywords field 2022-06-28 14:48:10 +02:00
Elio Struyf
6c7567a15c Fix when there is no title 2022-06-28 09:17:03 +02:00
Elio Struyf
be9797cc77 Updated gitignore 2022-06-28 09:05:28 +02:00
Elio Struyf
a78d9c5906 #364 - Honour file ending rules in data files 2022-06-28 09:04:38 +02:00
Elio Struyf
8f4fe45d9e Initial e2e test setup 2022-06-28 08:53:15 +02:00
Elio Struyf
79157feed5 #353 - Add the default content type on initialization 2022-06-16 11:53:08 +02:00
Elio Struyf
09888d5657 #291 - Hierarchy field support 2022-06-16 11:41:54 +02:00
Elio Struyf
a0371167bc #356 - fix schema for fieldGroups 2022-06-15 16:52:59 +02:00
Elio Struyf
0dc2623ded #358 - FIx for relative path of the public folder 2022-06-14 13:54:56 +02:00
Elio Struyf
7d81a83672 Merge branch 'main' into dev 2022-06-13 10:28:52 +02:00
Elio
17150a53bc 7.3.4 2022-06-13 10:23:57 +02:00
Elio
fbf1990045 Update version in changelog 2022-06-13 10:23:51 +02:00
Elio
c5881d7905 Update changelog 2022-06-13 10:20:47 +02:00
Elio
b83f7beb30 #354 - Windows file parsing fix 2022-06-13 10:19:20 +02:00
Elio
d2c5a850ef Keep panel open 2022-06-13 10:18:04 +02:00
Elio
5b334db3c9 #354 - Windows file parsing fix 2022-06-13 10:17:56 +02:00
Elio Struyf
69aa7a7648 Merge branch 'dev' of github.com:estruyf/vscode-front-matter into dev 2022-06-13 09:10:51 +02:00
Elio Struyf
97e4313d93 Move content type methods 2022-06-13 09:10:40 +02:00
Elio Struyf
3f7acd7e26 Merge branch 'main' into dev 2022-06-11 20:04:31 +02:00
Elio Struyf
7a2b45f031 Fix double pages on contents dashboard 2022-06-11 20:02:35 +02:00
Elio Struyf
8ed64691c4 7.3.3 2022-06-11 20:00:16 +02:00
Elio Struyf
844971cdd9 Fix card render 2022-06-11 20:00:03 +02:00
Elio Struyf
cf376cdda7 #291 - Taxonomy dashboard improvements + command 2022-06-10 15:50:49 +02:00
Elio Struyf
1a6acce77f #350 - New previewPath property for page folders 2022-06-10 10:52:10 +02:00
Elio Struyf
e9258e1a7f #351 - New template property for content types 2022-06-10 10:28:18 +02:00
Elio Struyf
61b461661d Update use count 2022-06-10 09:35:58 +02:00
Elio Struyf
a12a3852d2 Fixes for the table overflow 2022-06-10 09:34:35 +02:00
Elio Struyf
0c94b33606 Move taxonomy value 2022-06-09 16:22:53 +02:00
Elio Struyf
23f3fbfadf 8.0.0 2022-06-09 15:40:24 +02:00
Elio Struyf
434e87b074 Changes to the taxonomy dashboard 2022-06-09 15:40:21 +02:00
Elio Struyf
081fb7ce2e Start of the taxonomy dashboard implementation 2022-06-08 18:37:13 +02:00
Elio Struyf
bd43ba8a6d #349 - Slug field 2022-06-03 15:58:19 +02:00
Elio Struyf
bd2860e225 #307 - List field 2022-06-02 09:19:06 +02:00
Elio Struyf
daeaf0a59d 7.4.0 2022-06-01 13:48:29 +02:00
Elio Struyf
9cc7ea09d6 #345 - Improve the UI of the media dashboard 2022-06-01 13:48:24 +02:00
Elio Struyf
4b6f283bf3 #348 - breadcrumb fix 2022-06-01 13:48:12 +02:00
88 changed files with 15273 additions and 616 deletions

6
.gitignore vendored
View File

@@ -4,4 +4,8 @@ node_modules
*.vsix
.DS_Store
dist
todo.md
todo.md
e2e/storage
e2e/extensions
e2e/sample

14
.vscode/launch.json vendored
View File

@@ -29,20 +29,6 @@
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
]
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test"
],
"outFiles": [
"${workspaceFolder}/out/test/**/*.js"
],
"preLaunchTask": "npm: test-compile"
}
]
}

View File

@@ -10,8 +10,4 @@
"typescript.tsc.autoDetect": "off",
"eliostruyf.writingstyleguide.terms.isDisabled": true,
"eliostruyf.writingstyleguide.biasFree.isDisabled": true,
"exportall.config.folderListener": [
"/src/pagesView/state/atom",
"/src/pagesView/state/selectors"
]
}

View File

@@ -26,4 +26,6 @@ dist/*.html
frontmatter.json
.frontmatter
webpack
README.beta.md
README.beta.md
e2e
storage

View File

@@ -1,5 +1,43 @@
# Change Log
## [8.0.0] - 2022-07-11 - [Release notes](https://beta.frontmatter.codes/updates/v8.0.0)
### ✨ New Features
- [#291](https://github.com/estruyf/vscode-front-matter/issues/291): New taxonomy dashboard for managing tags, categories, and custom taxonomies
### 🎨 Enhancements
- Ignore the SEO `keywords` field for missing content type field
- [#307](https://github.com/estruyf/vscode-front-matter/issues/307): New `list` field which allows to create a list of items
- [#345](https://github.com/estruyf/vscode-front-matter/issues/345): Media dashboard UI improvements to visualize the content and public folders
- [#349](https://github.com/estruyf/vscode-front-matter/issues/349): New `slug` field which allows you to manage the slug of your post from the Front Matter panel
- [#350](https://github.com/estruyf/vscode-front-matter/issues/350): New `previewPath` property for the `frontMatter.content.pageFolders` setting. This allows you to specify a section prefix for all content created in that directory.
- [#351](https://github.com/estruyf/vscode-front-matter/issues/351): New `template` property for content types which allows you to combine templates and content types for content creation
- [#353](https://github.com/estruyf/vscode-front-matter/issues/353): Add the default content type on project initialization
- [#366](https://github.com/estruyf/vscode-front-matter/issues/366): Better support for using block fields in another block field
### 🐞 Fixes
- [#348](https://github.com/estruyf/vscode-front-matter/issues/348): Fix media dashboard breadcrumb when multiple page folders are in use
- [#356](https://github.com/estruyf/vscode-front-matter/issues/356): Re-introduce the `labelField` to the `frontMatter.taxonomy.fieldGroups` setting
- [#358](https://github.com/estruyf/vscode-front-matter/issues/358): Fix for relative path of the public folder
- [#364](https://github.com/estruyf/vscode-front-matter/issues/364): Honour file ending rules in data files
- [#365](https://github.com/estruyf/vscode-front-matter/issues/365): Show spinner on the initial load of the content dashboard
## [7.3.4] - 2022-06-13
### 🐞 Fixes
- [#354](https://github.com/estruyf/vscode-front-matter/issues/354): Fix Windows file path parsing for inserting media files
## [7.3.3] - 2022-06-11
### 🐞 Fixes
- Card render when taxonomy is not an array value
- Double pages on contents dashboard
## [7.3.2] - 2022-06-01
### 🐞 Fixes

View File

@@ -163,14 +163,6 @@
border: 1px solid rgba(0, 0, 0, .9);
}
.article__tags__input input {
border: 1px solid var(--vscode-inputValidation-infoBorder);
}
.article__tags__input input:disabled {
border-color: transparent;
}
.article__tags__input.freeform {
position: relative;
outline: 1px solid var(--vscode-inputValidation-infoBorder);
@@ -182,17 +174,6 @@
border: 0;
}
.article__tags__input button {
position: absolute;
bottom: 0;
top: 0;
right: 0;
width: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.article__tags ul {
color: var(--vscode-dropdown-foreground);
background-color: var(--vscode-dropdown-background);

80
e2e/src/command.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import { By, VSBrowser, EditorView, WebView, Workbench, Notification, StatusBar, NotificationType } from "vscode-extension-tester";
import { expect } from "chai";
import { sleep } from "./utils";
import { join } from "path";
// https://github.com/microsoft/vscode-java-dependency/blob/4256fa6adcaff5ec24dbdbb8d9a516fad21431c5/test/ui/index.ts
// https://github.com/microsoft/vscode-java-dependency/blob/4256fa6adcaff5ec24dbdbb8d9a516fad21431c5/test/ui/command.test.ts
describe("Initialization testing", function() {
this.timeout(2 * 60 * 1000 /*ms*/);
let workbench: Workbench;
let view: WebView;
before(async function() {
await VSBrowser.instance.openResources(join(__dirname, '../sample'));
await sleep(3000);
workbench = new Workbench();
await workbench.executeCommand("frontMatter.dashboard");
await sleep(3000);
await new EditorView().openEditor(`FrontMatter Dashboard`);
view = new WebView();
await view.switchToFrame();
});
it("1. Open welcome dashboard", async function() {
const element = await view.findWebElement(By.css('h1'));
const title = await element.getText();
expect(title).has.string(`Front Matter`);
});
it("2. Initialize project", async function() {
const btn = await view.findWebElement(By.css('[data-test="welcome-init"] button'));
expect(btn).to.exist;
await btn.click();
await sleep(1000);
await VSBrowser.instance.driver.wait(() => {
return notificationExists(workbench, 'Front Matter:');
}, 2000) as Notification;
const notifications = await workbench.getNotifications();
let notification!: Notification;
for (const not of notifications) {
console.log(not);
// const message = await not.get;
// console.log(message);
// if (message.includes('Front Matter:')) {
// notification = not;
// }
}
expect(await notification.getMessage()).has.string(`Project initialized successfully.`);
});
it("3. Check if project file is created", async function() {});
});
async function notificationExists(workbench: Workbench, text: string): Promise<Notification | undefined> {
const notifications = await (await (new StatusBar()).openNotificationsCenter()).getNotifications(NotificationType.Info);
console.log(`Notifications:`, notifications.length);
for (const notification of notifications) {
const message = await notification.getMessage();
console.log(message)
if (message.indexOf(text) >= 0) {
return notification;
}
}
}

33
e2e/src/runTests.ts Normal file
View File

@@ -0,0 +1,33 @@
import * as path from 'path';
import * as semver from "semver";
import { ExTester, ReleaseQuality } from "vscode-extension-tester";
async function main(): Promise<void> {
const vsCodeVersion: semver.SemVer = new semver.SemVer(`1.66.0`);
const version = vsCodeVersion.version;
const storageFolder = path.join(__dirname, "..", "storage");
const extFolder = path.join(__dirname, "..", "extensions");
try {
const testPath = path.join(__dirname, "command.test.js");
const exTester = new ExTester(storageFolder, ReleaseQuality.Stable, extFolder);
await exTester.downloadCode(version);
await exTester.installVsix();
// await exTester.installFromMarketplace("eliostruyf.vscode-front-matter");
await exTester.downloadChromeDriver(version);
// await exTester.setupRequirements({vscodeVersion: version});
const result = await exTester.runTests(testPath, {
vscodeVersion: version
});
process.exit(result);
} catch (err) {
console.log(err);
process.exit(1);
}
}
main();

1
e2e/src/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './sleep';

3
e2e/src/utils/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export async function sleep(time: number) {
await new Promise((resolve) => setTimeout(resolve, time));
}

12978
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"displayName": "Front Matter",
"description": "Front Matter is a CMS that runs within Visual Studio Code. It gives you the power and control of a full-blown CMS while also providing you the flexibility and speed of the static site generator of your choice like: Hugo, Jekyll, Hexo, NextJs, Gatsby, and many more...",
"icon": "assets/frontmatter-teal-128x128.png",
"version": "7.3.2",
"version": "8.0.0",
"preview": false,
"publisher": "eliostruyf",
"galleryBanner": {
@@ -22,6 +22,9 @@
"href": "https://www.buymeacoffee.com/zMeFRy9"
}
],
"sponsor": {
"url": "https://github.com/sponsors/estruyf"
},
"engines": {
"vscode": "^1.63.0"
},
@@ -183,6 +186,14 @@
"type": "boolean",
"default": false,
"description": "Exclude sub-directories"
},
"previewPath": {
"type": [
"null",
"string"
],
"default": null,
"description": "Defines a custom preview path for the folder."
}
},
"additionalProperties": false,
@@ -806,7 +817,9 @@
"fields",
"json",
"block",
"dataFile"
"list",
"dataFile",
"slug"
],
"description": "Define the type of field"
},
@@ -943,6 +956,11 @@
"type": "string",
"default": "",
"description": "Specify the property name that will be used to show the value for the field"
},
"editable": {
"type": "boolean",
"default": true,
"description": "Specify if the field is editable"
}
},
"additionalProperties": false,
@@ -1065,6 +1083,11 @@
],
"default": null,
"description": "Defines a custom preview path for the content type."
},
"template": {
"type": "string",
"default": "",
"description": "An optional template that can be used for creating new content."
}
},
"additionalProperties": false,
@@ -1168,6 +1191,10 @@
"type": "string",
"description": "The name of the field group"
},
"labelField": {
"type": "string",
"description": "The name of the field to be used as display value"
},
"fields": {
"$ref": "#contenttypefield"
}
@@ -1504,6 +1531,15 @@
"light": "/assets/icons/frontmatter-small-light.svg"
}
},
{
"command": "frontMatter.dashboard.taxonomy",
"title": "Open taxonomy dashboard",
"category": "Front matter",
"icon": {
"dark": "/assets/icons/frontmatter-small-dark.svg",
"light": "/assets/icons/frontmatter-small-light.svg"
}
},
{
"command": "frontMatter.markup.orderedlist",
"title": "Ordered list",
@@ -1819,7 +1855,7 @@
{
"command": "frontMatter.dashboard",
"group": "navigation@2",
"when": "view == frontMatter.explorer"
"when": "view == frontMatter.explorer || view == explorer"
}
]
},
@@ -1890,7 +1926,9 @@
"prod:panel": "webpack --mode production --config ./webpack/panel.config.js",
"test-compile": "tsc -p ./",
"clean": "rimraf dist",
"start:site": "cd ./docs && npm run dev"
"start:site": "cd ./docs && npm run dev",
"clean:test": "rm ./e2e/sample/frontmatter.json || exit 0 && rm -rf ./e2e/sample/.frontmatter || exit 0",
"test": "tsc -p tsconfig.e2e.json && npm run clean:test && node ./e2e/out/runTests.js"
},
"devDependencies": {
"@actions/core": "^1.8.2",
@@ -1903,6 +1941,7 @@
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",
"@tailwindcss/forms": "^0.3.3",
"@types/chai": "^4.3.1",
"@types/glob": "7.1.3",
"@types/invariant": "^2.2.35",
"@types/js-yaml": "3.12.1",
@@ -1910,7 +1949,7 @@
"@types/lodash.uniqby": "4.7.6",
"@types/lodash.xor": "^4.5.6",
"@types/mime-types": "^2.1.1",
"@types/mocha": "^5.2.6",
"@types/mocha": "^5.2.7",
"@types/mustache": "^4.1.2",
"@types/node": "10.17.48",
"@types/node-fetch": "^2.5.12",
@@ -1925,6 +1964,7 @@
"ajv": "^8.8.2",
"array-move": "^4.0.0",
"autoprefixer": "^10.3.2",
"chai": "^4.3.6",
"css-loader": "5.2.7",
"date-fns": "2.23.0",
"downshift": "6.0.6",
@@ -1941,6 +1981,7 @@
"lodash.xor": "^4.5.0",
"mdast-util-from-markdown": "1.0.0",
"mime-types": "^2.1.35",
"mocha": "^10.0.0",
"mustache": "^4.2.0",
"node-json-db": "^1.3.0",
"npm-run-all": "^4.1.5",
@@ -1953,10 +1994,12 @@
"react-dom": "17.0.1",
"react-dropzone": "^11.3.4",
"react-quill": "^2.0.0-beta.4",
"react-router-dom": "^6.3.0",
"react-sortable-hoc": "^2.0.0",
"react-toastify": "^8.1.0",
"recoil": "^0.4.1",
"rimraf": "^3.0.2",
"semver": "^7.3.7",
"style-loader": "2.0.0",
"tailwindcss": "^2.2.7",
"tailwindcss-nested-groups": "^1.2.4",
@@ -1968,6 +2011,7 @@
"uniforms-bridge-json-schema": "^3.7.0",
"uniforms-unstyled": "^3.7.0",
"url-join-ts": "^1.0.5",
"vscode-extension-tester": "^4.2.5",
"wc-react": "github:estruyf/wc-react",
"webpack": "^5.65.0",
"webpack-bundle-analyzer": "^4.5.0",

View File

@@ -168,13 +168,34 @@ export class Article {
}
/**
* Generate the slug based on the article title
* Generate the new slug
*/
public static async generateSlug() {
Telemetry.send(TelemetryEvent.generateSlug);
public static generateSlug(title: string) {
if (!title) {
return;
}
const prefix = Settings.get(SETTING_SLUG_PREFIX) as string;
const suffix = Settings.get(SETTING_SLUG_SUFFIX) as string;
const slug = SlugHelper.createSlug(title);
if (slug) {
return {
slug,
slugWithPrefixAndSuffix: `${prefix}${slug}${suffix}`
};
}
return undefined;
}
/**
* Generate the slug based on the article title
*/
public static async updateSlug() {
Telemetry.send(TelemetryEvent.generateSlug);
const updateFileName = Settings.get(SETTING_SLUG_UPDATE_FILE_NAME) as string;
const filePrefix = Settings.get<string>(SETTING_TEMPLATES_PREFIX);
const editor = vscode.window.activeTextEditor;
@@ -191,17 +212,16 @@ export class Article {
const contentType = ArticleHelper.getContentType(article.data);
const titleField = "title";
const articleTitle: string = article.data[titleField];
const slugInfo = Article.generateSlug(articleTitle);
const slug = SlugHelper.createSlug(articleTitle);
if (slug) {
let slugFieldValue = `${prefix}${slug}${suffix}`;
article.data["slug"] = slugFieldValue;
if (slugInfo && slugInfo.slug && slugInfo.slugWithPrefixAndSuffix) {
article.data["slug"] = slugInfo.slugWithPrefixAndSuffix;
if (contentType) {
// Update the fields containing the slug placeholder
let fieldsToUpdate: Field[] = contentType.fields.filter(f => f.default === "{{slug}}");
for (const field of fieldsToUpdate) {
article.data[field.name] = slug;
article.data[field.name] = slugInfo.slug;
}
// Update the fields containing a custom placeholder that depends on slug
@@ -227,7 +247,7 @@ export class Article {
const ext = extname(editor.document.fileName);
const fileName = basename(editor.document.fileName);
let slugName = slug.startsWith("/") ? slug.substring(1) : slug;
let slugName = slugInfo.slug.startsWith("/") ? slugInfo.slug.substring(1) : slugInfo.slug;
slugName = slugName.endsWith("/") ? slugName.substring(0, slugName.length - 1) : slugName;
let newFileName = `${slugName}${ext}`;

View File

@@ -8,6 +8,7 @@ export class Content {
const templatesEnabled = await Settings.get(SETTING_TEMPLATES_ENABLED);
if (!templatesEnabled) {
commands.executeCommand(COMMAND_NAME.createByContentType);
return;
}
const options: QuickPickItem[] = [{

View File

@@ -1,4 +1,4 @@
import { SETTING_DASHBOARD_OPENONSTART, CONTEXT } from '../constants';
import { SETTING_DASHBOARD_OPENONSTART, CONTEXT, ExtensionState } from '../constants';
import { join } from "path";
import { commands, Uri, ViewColumn, Webview, WebviewPanel, window } from "vscode";
import { Logger, Settings as SettingsHelper } from '../helpers';
@@ -7,7 +7,7 @@ import { Extension } from '../helpers/Extension';
import { WebviewHelper } from '@estruyf/vscode';
import { DashboardData } from '../models/DashboardData';
import { MediaLibrary } from '../helpers/MediaLibrary';
import { DashboardListener, MediaListener, SettingsListener, TelemetryListener, DataListener, PagesListener, ExtensionListener, SnippetListener } from '../listeners/dashboard';
import { DashboardListener, MediaListener, SettingsListener, TelemetryListener, DataListener, PagesListener, ExtensionListener, SnippetListener, TaxonomyListener } from '../listeners/dashboard';
import { MediaListener as PanelMediaListener } from '../listeners/panel'
import { ModeListener } from '../listeners/general';
@@ -74,6 +74,7 @@ export class Dashboard {
public static reload() {
if (Dashboard.isOpen) {
Dashboard.webview?.dispose();
Extension.getInstance().setState(ExtensionState.Dashboard.Pages.Cache, undefined, "workspace")
setTimeout(() => {
Dashboard.open();
@@ -145,6 +146,7 @@ export class Dashboard {
TelemetryListener.process(msg);
SnippetListener.process(msg);
ModeListener.process(msg);
TaxonomyListener.process(msg);
});
}

View File

@@ -52,7 +52,7 @@ ${folderData.join("\n")}
let projectStart = folder.path.split(projectName).pop();
projectStart = projectStart || "";
projectStart = projectStart?.replace(/\\/g, '/');
projectStart = projectStart?.startsWith('/') ? projectStart.substr(1) : projectStart;
projectStart = projectStart?.startsWith('/') ? projectStart.substring(1) : projectStart;
const mdFiles = await workspace.findFiles(join(projectStart, folder.excludeSubdir ? '/' : '**/', '*.md'));
const mdxFiles = await workspace.findFiles(join(projectStart, folder.excludeSubdir ? '/' : '**/', '*.mdx'));

View File

@@ -27,7 +27,7 @@ export class Folders {
*/
public static async addMediaFolder(data?: {selectedFolder?: string}) {
let wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
let staticFolder = Folders.getStaticFolderRelativePath();
let startPath = "";
@@ -153,6 +153,25 @@ export class Folders {
}
}
/**
* Get the static folder its relative path
* @returns
*/
public static getStaticFolderRelativePath(): string | undefined {
let staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
if (staticFolder && staticFolder.includes(WORKSPACE_PLACEHOLDER)) {
staticFolder = Folders.getAbsFilePath(staticFolder);
const wsFolder = Folders.getWorkspaceFolder();
if (wsFolder) {
const relativePath = relative(parseWinPath(wsFolder.fsPath), parseWinPath(staticFolder));
return relativePath;
}
}
return staticFolder;
}
/**
* Retrieve the folder path
* @param folder
@@ -231,7 +250,7 @@ export class Folders {
if (projectStart) {
projectStart = projectStart.replace(/\\/g, '/');
projectStart = projectStart.startsWith('/') ? projectStart.substr(1) : projectStart;
projectStart = projectStart.startsWith('/') ? projectStart.substring(1) : projectStart;
let files: Uri[] = [];

View File

@@ -3,13 +3,14 @@ import { SETTING_PREVIEW_HOST, SETTING_PREVIEW_PATHNAME, CONTEXT, TelemetryEvent
import { ArticleHelper } from './../helpers/ArticleHelper';
import { join } from "path";
import { commands, env, Uri, ViewColumn, window } from "vscode";
import { Extension, Settings } from '../helpers';
import { PreviewSettings } from '../models';
import { Extension, parseWinPath, Settings } from '../helpers';
import { ContentFolder, PreviewSettings } from '../models';
import { format } from 'date-fns';
import { DateHelper } from '../helpers/DateHelper';
import { Article } from '.';
import { urlJoin } from 'url-join-ts';
import { WebviewHelper } from '@estruyf/vscode';
import { Folders } from './Folders';
export class Preview {
@@ -37,6 +38,28 @@ export class Preview {
let slug = article?.data ? article.data.slug : "";
let pathname = settings.pathname;
// Check if there is a pathname defined on content folder level
const folders = Folders.get();
if (folders.length > 0) {
const foldersWithPath = folders.filter(folder => folder.previewPath);
const filePath = parseWinPath(editor?.document.uri.fsPath);
let selectedFolder: ContentFolder | null = null;
for (const folder of foldersWithPath) {
if (filePath.startsWith(folder.path)) {
if (!selectedFolder || selectedFolder.path.length < folder.path.length) {
selectedFolder = folder;
}
}
}
if (selectedFolder) {
pathname = selectedFolder.previewPath;
}
}
// Check if there is a pathname defined on content type level
if (article?.data) {
const contentType = ArticleHelper.getContentType(article.data);
if (contentType && contentType.previewPath) {

View File

@@ -1,3 +1,4 @@
import { DEFAULT_CONTENT_TYPE } from './../constants/ContentType';
import { Telemetry } from './../helpers/Telemetry';
import { workspace, Uri } from "vscode";
import { join } from "path";
@@ -6,7 +7,7 @@ import { Notifications } from "../helpers/Notifications";
import { Template } from "./Template";
import { Folders } from "./Folders";
import { FrameworkDetector, Logger, Settings } from "../helpers";
import { SETTING_CONTENT_DEFAULT_FILETYPE, TelemetryEvent } from "../constants";
import { SETTING_CONTENT_DEFAULT_FILETYPE, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants";
import { SettingsListener } from '../listeners/dashboard';
export class Project {
@@ -35,6 +36,9 @@ categories: []
try {
Settings.createTeamSettings();
// Add the default content type
Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, [DEFAULT_CONTENT_TYPE], true);
if (sampleTemplate !== undefined) {
await Project.createSampleTemplate();
} else {

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.`);
});
}
}

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>
);
};

View File

@@ -32,6 +32,7 @@ export const COMMAND_NAME = {
dashboardMedia: getCommandName("dashboard.media"),
dashboardSnippets: getCommandName("dashboard.snippets"),
dashboardData: getCommandName("dashboard.data"),
dashboardTaxonomy: getCommandName("dashboard.taxonomy"),
dashboardClose: getCommandName("dashboard.close"),
promote: getCommandName("promoteSettings"),
createFolder: getCommandName("createFolder"),

View File

@@ -17,6 +17,9 @@ export const FEATURE_FLAG = {
},
data: {
view: "dashboard.data.view",
},
taxonomy: {
view: "dashboard.taxonomy.view"
}
}
};

View File

@@ -10,6 +10,7 @@ export const TelemetryEvent = {
openMediaDashboard: 'openMediaDashboard',
openDataDashboard: 'openDataDashboard',
openSnippetsDashboard: 'openSnippetsDashboard',
openTaxonomyDashboard: 'openTaxonomyDashboard',
closeDashboard: 'closeDashboard',
// Other actions
@@ -41,4 +42,5 @@ export const TelemetryEvent = {
webviewDataView: 'webviewDataView',
webviewContentsView: 'webviewContentsView',
webviewSnippetsView: 'webviewSnippetsView',
webviewTaxonomyDashboard: 'webviewTaxonomyDashboard',
};

View File

@@ -8,4 +8,7 @@ export enum DashboardCommand {
mediaUpdate = "mediaUpdate",
dataFileEntries = "dataFileEntries",
searchReady = "searchReady",
// Taxonomy dashboard
setTaxonomyData = "setTaxonomyData",
}

View File

@@ -41,6 +41,16 @@ export enum DashboardMessage {
addSnippet = 'addSnippet',
updateSnippet = 'updateSnippet',
// Taxonomy dashboard
getTaxonomyData = 'getTaxonomyData',
editTaxonomy = "editTaxonomy",
mergeTaxonomy = "mergeTaxonomy",
deleteTaxonomy = "deleteTaxonomy",
addToTaxonomy = "addToTaxonomy",
createTaxonomy = "createTaxonomy",
importTaxonomy = "importTaxonomy",
moveTaxonomy = "moveTaxonomy",
// Other
getTheme = 'getTheme',
updateSetting = 'updateSetting',

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import { Spinner } from './Spinner';
import useMessages from '../hooks/useMessages';
import useDarkMode from '../../hooks/useDarkMode';
import { WelcomeScreen } from './WelcomeScreen';
import { useRecoilValue } from 'recoil';
import { DashboardViewSelector, ModeAtom } from '../state';
import { Contents } from './Contents/Contents';
import { Media } from './Media/Media';
import { DataView } from './DataView';
import { Snippets } from './SnippetsView/Snippets';
import { FEATURE_FLAG } from '../../constants';
import { Messenger } from '@estruyf/vscode/dist/client';
import { TaxonomyView } from './TaxonomyView';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { routePaths } from '..';
import { useEffect, useMemo } from 'react';
import { UnknownView } from './UnknownView';
export interface IAppProps {
showWelcome: boolean;
}
export const App: React.FunctionComponent<IAppProps> = ({showWelcome}: React.PropsWithChildren<IAppProps>) => {
const { loading, pages, settings } = useMessages();
const view = useRecoilValue(DashboardViewSelector);
const mode = useRecoilValue(ModeAtom);
const navigate = useNavigate();
useDarkMode();
const viewState: any = Messenger.getState() || {};
const isAllowed = (features: string[], flag: string) => {
if (!features ||( features.length > 0 && !features.includes(flag))) {
return false;
}
return true;
}
const allowDataView = useMemo(() => {
return isAllowed(mode?.features || [], FEATURE_FLAG.dashboard.data.view)
}, [mode?.features]);
const allowTaxonomyView = useMemo(() => {
return isAllowed(mode?.features || [], FEATURE_FLAG.dashboard.taxonomy.view)
}, [mode?.features]);
useEffect(() => {
if (view && routePaths[view]) {
navigate(routePaths[view]);
return;
}
navigate(routePaths[view]);
}, [view]);
if (!settings) {
return <Spinner />;
}
if (showWelcome || viewState.isWelcomeConfiguring) {
return <WelcomeScreen settings={settings} />;
}
if (!settings.initialized || settings.contentFolders?.length === 0) {
return <WelcomeScreen settings={settings} />;
}
return (
<main className={`h-full w-full`}>
<Routes>
<Route path={routePaths.welcome} element={<WelcomeScreen settings={settings} />} />
<Route path={routePaths.contents} element={<Contents pages={pages} loading={loading} />} />
<Route path={routePaths.media} element={<Media />} />
<Route path={routePaths.snippets} element={<Snippets />} />
{
allowDataView && <Route path={routePaths.data} element={<DataView />} />
}
{
allowTaxonomyView && <Route path={routePaths.taxonomy} element={<TaxonomyView pages={pages} />} />
}
<Route path={`*`} element={<UnknownView />} />
</Routes>
</main>
);
};

View File

@@ -12,10 +12,11 @@ export interface IChoiceButtonProps {
onClick: () => void;
}[];
disabled?: boolean;
isTemplatesEnabled?: boolean;
onClick: () => void;
}
export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onClick, disabled, choices, title}: React.PropsWithChildren<IChoiceButtonProps>) => {
export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onClick, disabled, choices, isTemplatesEnabled, title}: React.PropsWithChildren<IChoiceButtonProps>) => {
return (
<span className="relative z-50 inline-flex shadow-sm rounded-md">
<button
@@ -27,36 +28,40 @@ export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onCli
{title}
</button>
<Menu as="span" className="-ml-px relative block">
<Menu.Button
className="h-full inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-700 hover:bg-teal-800 focus:outline-none disabled:bg-gray-500"
disabled={disabled}>
<span className="sr-only">Open options</span>
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
{
isTemplatesEnabled && (
<Menu as="span" className="-ml-px relative block">
<Menu.Button
className="h-full inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-700 hover:bg-teal-800 focus:outline-none disabled:bg-gray-500"
disabled={disabled}>
<span className="sr-only">Open options</span>
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
<MenuItems widthClass={`w-56`}>
<div className="py-1">
{choices.map((choice, idx) => (
<MenuItem
key={idx}
title={(
choice.icon ? (
<div className="flex items-center">
{choice.icon}
<span>{choice.title}</span>
</div>
) : (
choice.title
)
)}
value={null}
onClick={choice.onClick}
disabled={choice.disabled} />
))}
</div>
</MenuItems>
</Menu>
<MenuItems widthClass={`w-56`}>
<div className="py-1">
{choices.map((choice, idx) => (
<MenuItem
key={idx}
title={(
choice.icon ? (
<div className="flex items-center">
{choice.icon}
<span>{choice.title}</span>
</div>
) : (
choice.title
)
)}
value={null}
onClick={choice.onClick}
disabled={choice.disabled} />
))}
</div>
</MenuItems>
</Menu>
)
}
</span>
);
};

View File

@@ -30,7 +30,20 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
}
const tagField = settings.dashboardState.contents.tags;
return pageData[tagField] || [];
if (tagField === "tags") {
return pageData.fmTags;
} else if (tagField === "categories") {
return pageData.fmCategories;
}
const tagsValue = pageData[tagField] || [];
if (Array.isArray(tagsValue)) {
return tagsValue;
}
return [tagsValue];
}, [settings, pageData]);
if (view === DashboardViewType.Grid) {

View File

@@ -1,72 +0,0 @@
import * as React from 'react';
import { Spinner } from './Spinner';
import useMessages from '../hooks/useMessages';
import useDarkMode from '../../hooks/useDarkMode';
import { WelcomeScreen } from './WelcomeScreen';
import { useRecoilValue } from 'recoil';
import { DashboardViewSelector, ModeAtom } from '../state';
import { Contents } from './Contents/Contents';
import { Media } from './Media/Media';
import { NavigationType } from '../models';
import { DataView } from './DataView';
import { Snippets } from './SnippetsView/Snippets';
import { FeatureFlag } from '../../components/features/FeatureFlag';
import { FEATURE_FLAG } from '../../constants';
import { Messenger } from '@estruyf/vscode/dist/client';
export interface IDashboardProps {
showWelcome: boolean;
}
export const Dashboard: React.FunctionComponent<IDashboardProps> = ({showWelcome}: React.PropsWithChildren<IDashboardProps>) => {
const { loading, pages, settings } = useMessages();
const view = useRecoilValue(DashboardViewSelector);
const mode = useRecoilValue(ModeAtom);
useDarkMode();
const viewState: any = Messenger.getState() || {};
if (!settings) {
return <Spinner />;
}
if (showWelcome || viewState.isWelcomeConfiguring) {
return <WelcomeScreen settings={settings} />;
}
if (!settings.initialized || settings.contentFolders?.length === 0) {
return <WelcomeScreen settings={settings} />;
}
if (view === NavigationType.Snippets) {
return (
<main className={`h-full w-full`}>
<Snippets />
</main>
);
}
if (view === NavigationType.Media) {
return (
<main className={`h-full w-full`}>
<Media />
</main>
);
}
if (view === NavigationType.Data) {
return (
<FeatureFlag features={mode?.features || []} flag={FEATURE_FLAG.dashboard.data.view}>
<main className={`h-full w-full`}>
<DataView />
</main>
</FeatureFlag>
);
}
return (
<main className={`h-full w-full`}>
<Contents pages={pages} loading={loading} />
</main>
);
};

View File

@@ -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>
)
))
}
@@ -172,7 +172,7 @@ export const DataView: React.FunctionComponent<IDataViewProps> = (props: React.P
selectedData ? (
<>
<div className={`w-1/3 py-6 px-4 flex-1 border-r border-gray-200 dark:border-vulcan-300 overflow-auto`}>
<h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>Your {selectedData.title.toLowerCase()} data items</h2>
<h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>Your {selectedData?.title?.toLowerCase() || ""} data items</h2>
<div className='py-4'>
{
@@ -245,4 +245,4 @@ export const DataView: React.FunctionComponent<IDataViewProps> = (props: React.P
<ToastContainer />
</div>
);
};
};

View File

@@ -38,14 +38,18 @@ export const Breadcrumb: React.FunctionComponent<IBreadcrumbProps> = (props: Rea
}
}
let valid = false;
for (let i = 0; i < contentFolders.length; i++) {
const folder = contentFolders[i];
const contentFolder = parseWinPath(folder.path) as string;
const relContentPath = folderPath.replace(contentFolder, '');
return relContentPath.length > 1 && folderPath.startsWith(contentFolder);
if (!valid) {
valid = relContentPath.length > 1 && folderPath.startsWith(contentFolder);
}
}
return false;
return valid;
};
if (!selectedFolder) {

View File

@@ -4,6 +4,8 @@ import { useRecoilValue, useResetRecoilState } from 'recoil';
import { FolderSelector, TagSelector, CategorySelector, SortingAtom, FolderAtom, DEFAULT_FOLDER_STATE, TagAtom, CategoryAtom, DEFAULT_TAG_STATE, DEFAULT_CATEGORY_STATE } from '../../state';
import { DefaultValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
export const guardRecoilDefaultValue = (
candidate: any
@@ -34,7 +36,7 @@ export const ClearFilters: React.FunctionComponent<IClearFiltersProps> = (props:
resetCategory();
};
React.useEffect(() => {
useEffect(() => {
if (folder !== DEFAULT_FOLDER_STATE || tag !== DEFAULT_TAG_STATE || category !== DEFAULT_CATEGORY_STATE) {
setShow(true);
} else {

View File

@@ -10,7 +10,7 @@ import { Navigation } from '../Navigation';
import { Grouping } from '.';
import { ViewSwitch } from './ViewSwitch';
import { useRecoilState, useResetRecoilState } from 'recoil';
import { CategoryAtom, DashboardViewAtom, SortingAtom, TagAtom } from '../../state';
import { CategoryAtom, SortingAtom, TagAtom } from '../../state';
import { Messenger } from '@estruyf/vscode/dist/client';
import { ClearFilters } from './ClearFilters';
import { MediaHeaderTop } from '../Media/MediaHeaderTop';
@@ -19,6 +19,9 @@ import { MediaHeaderBottom } from '../Media/MediaHeaderBottom';
import { Tabs } from './Tabs';
import { CustomScript } from '../../../models';
import { LightningBoltIcon, PlusIcon } from '@heroicons/react/outline';
import { useLocation, useNavigate } from 'react-router-dom';
import { routePaths } from '../..';
import { useEffect } from 'react';
export interface IHeaderProps {
header?: React.ReactNode;
@@ -34,8 +37,9 @@ export interface IHeaderProps {
export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPages, folders, settings }: React.PropsWithChildren<IHeaderProps>) => {
const [ crntTag, setCrntTag ] = useRecoilState(TagAtom);
const [ crntCategory, setCrntCategory ] = useRecoilState(CategoryAtom);
const [ view, setView ] = useRecoilState(DashboardViewAtom);
const resetSorting = useResetRecoilState(SortingAtom)
const resetSorting = useResetRecoilState(SortingAtom);
const location = useLocation();
const navigate = useNavigate();
const createContent = () => {
Messenger.send(DashboardMessage.createContent);
@@ -50,7 +54,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
};
const updateView = (view: NavigationType) => {
setView(view);
navigate(routePaths[view]);
resetSorting();
}
@@ -68,6 +72,27 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
onClick: () => runBulkScript(s)
}));
useEffect(() => {
if (location.search) {
const searchParams = new URLSearchParams(location.search);
const taxonomy = searchParams.get("taxonomy");
const value = searchParams.get("value");
if (taxonomy && value) {
if (taxonomy === "tags") {
setCrntTag(value);
} else if (taxonomy === "categories") {
setCrntCategory(value);
}
}
return;
}
setCrntTag("");
setCrntCategory("");
}, [location.search]);
return (
<div className={`w-full sticky top-0 z-40 bg-gray-100 dark:bg-vulcan-500`}>
@@ -76,7 +101,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
</div>
{
view === NavigationType.Contents && (
location.pathname === routePaths.contents && (
<>
<div className={`px-4 mt-3 mb-2 flex items-center justify-between`}>
<Searchbox />
@@ -109,6 +134,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
...customActions
]}
onClick={createContent}
isTemplatesEnabled={settings?.dashboardState?.contents?.templatesEnabled || undefined}
disabled={!settings?.initialized} />
</div>
</div>
@@ -141,7 +167,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
}
{
view === NavigationType.Media && (
location.pathname === routePaths.media && (
<>
<MediaHeaderTop />

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { NavigationType } from '../../models';
import { DashboardViewAtom } from '../../state';
export interface ITabProps {
navigationType: NavigationType;
@@ -9,11 +8,11 @@ export interface ITabProps {
}
export const Tab: React.FunctionComponent<ITabProps> = ({navigationType, onNavigate, children}: React.PropsWithChildren<ITabProps>) => {
const view = useRecoilValue(DashboardViewAtom);
const location = useLocation();
return (
<button
className={`h-full flex items-center py-2 px-4 text-sm font-medium text-center border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 ${view === navigationType ? "border-vulcan-500 text-vulcan-500 dark:border-whisper-500 dark:text-whisper-500" : "text-gray-500 dark:text-gray-400"}`}
className={`h-full flex items-center py-2 px-4 text-sm font-medium text-center border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 ${location.pathname === `/${navigationType}` ? "border-vulcan-500 text-vulcan-500 dark:border-whisper-500 dark:text-whisper-500" : "text-gray-500 dark:text-gray-400"}`}
type="button"
role="tab"
aria-controls="profile"

View File

@@ -1,4 +1,4 @@
import { DatabaseIcon, PhotographIcon, ScissorsIcon } from '@heroicons/react/outline';
import { DatabaseIcon, PhotographIcon, ScissorsIcon, TagIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { useRecoilValue } from 'recoil';
import { FeatureFlag } from '../../../components/features/FeatureFlag';
@@ -49,6 +49,15 @@ export const Tabs: React.FunctionComponent<ITabsProps> = ({ onNavigate }: React.
</Tab>
</li>
</FeatureFlag>
<FeatureFlag features={mode?.features || []} flag={FEATURE_FLAG.dashboard.taxonomy.view}>
<li className="mr-2" role="presentation">
<Tab
navigationType={NavigationType.Taxonomy}
onNavigate={onNavigate}>
<TagIcon className={`h-6 w-auto mr-2`} /><span>Taxonomy</span>
</Tab>
</li>
</FeatureFlag>
</ul>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
export * from './NavigationBar';
export * from './NavigationItem';
export * from './PageLayout';

View File

@@ -61,10 +61,14 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
const getRelPath = () => {
let relPath: string | undefined = "";
if (settings?.wsFolder && media.fsPath) {
relPath = media.fsPath.split(settings.wsFolder).pop();
const wsFolderParsed = parseWinPath(settings.wsFolder);
const mediaParsed = parseWinPath(media.fsPath);
relPath = mediaParsed.split(wsFolderParsed).pop();
if (settings.staticFolder && relPath) {
relPath = relPath.split(settings.staticFolder).pop();
const staticFolderParsed = parseWinPath(settings.staticFolder);
relPath = relPath.split(staticFolderParsed).pop();
}
}
return relPath;

View File

@@ -17,7 +17,7 @@ import useMedia from '../../hooks/useMedia';
import { TelemetryEvent } from '../../../constants';
import { PageLayout } from '../Layout/PageLayout';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { extname, join } from 'path';
import { basename, extname, join } from 'path';
export interface IMediaProps {}
@@ -29,14 +29,27 @@ export const Media: React.FunctionComponent<IMediaProps> = (props: React.PropsWi
const folders = useRecoilValue(MediaFoldersAtom);
const loading = useRecoilValue(LoadingAtom);
const allFolders = React.useMemo(() => {
const contentFolders = React.useMemo(() => {
// Check if content allows page bundle
if (viewData && viewData.data && typeof viewData.data.pageBundle !== "undefined" && !viewData.data.pageBundle) {
return folders.filter(f => parseWinPath(f).includes(join('/', settings?.staticFolder || '', '/')));
return [];
}
return folders;
let groupedFolders = [];
for (const cFolder of (settings?.contentFolders || [])) {
const foldersPath = parseWinPath(cFolder.path);
groupedFolders.push({
title: cFolder.title || basename(cFolder.path),
folders: folders.filter(f => parseWinPath(f).startsWith(foldersPath))
});
}
return groupedFolders;
}, [folders, viewData, settings?.contentFolders]);
const publicFolders = React.useMemo(() => {
return folders.filter(f => parseWinPath(f).includes(join('/', settings?.staticFolder || '', '/')));
}, [folders, viewData, settings?.staticFolder]);
const allMedia = React.useMemo(() => {
@@ -126,11 +139,33 @@ export const Media: React.FunctionComponent<IMediaProps> = (props: React.PropsWi
}
{
allFolders && allFolders.length > 0 && (
contentFolders && contentFolders.length > 0 && contentFolders.map(group => (
group.folders && group.folders.length > 0 && (
<div className={`mb-8`}>
<h2 className='text-lg mb-8 first-letter:uppercase'>Content folder: <b>{group.title}</b></h2>
<List gap={0}>
{
group.folders.map((folder) => (
<FolderItem key={folder} folder={folder} staticFolder={settings?.staticFolder} wsFolder={settings?.wsFolder} />
))
}
</List>
</div>
)
))
}
{
publicFolders && publicFolders.length > 0 && (
<div className={`mb-8`}>
{
contentFolders && contentFolders.length > 0 && (<h2 className='text-lg mb-8'>Public folder{settings?.staticFolder && (<span>: <b>{settings?.staticFolder}</b></span>)}</h2>)
}
<List gap={0}>
{
allFolders.map((folder) => (
publicFolders.map((folder) => (
<FolderItem key={folder} folder={folder} staticFolder={settings?.staticFolder} wsFolder={settings?.wsFolder} />
))
}

View File

@@ -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`}>

View File

@@ -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>
);
};

View File

@@ -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);
@@ -47,6 +48,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
const reload = () => {
const crntState: any = Messenger.getState() || {};
Messenger.setState({
...crntState,
isWelcomeConfiguring: false
@@ -55,14 +57,21 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
Messenger.send(DashboardMessage.reload);
};
const importTaxonomy = () => {
Messenger.send(DashboardMessage.importTaxonomy);
setTaxImported(true);
}
const steps = [
{
id: `welcome-init`,
name: 'Initialize project',
description: <>Initialize the project with a template folder and sample markdown file. The template folder can be used to define your own templates. <b>Start by clicking on this action</b>.</>,
status: settings.initialized ? Status.Completed : Status.NotStarted,
onClick: settings.initialized ? undefined : () => { Messenger.send(DashboardMessage.initializeProject); }
},
{
id: `welcome-framework`,
name: 'Framework presets',
description: (
<div>
@@ -106,6 +115,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
onClick: undefined
},
{
id: `welcome-content-folders`,
name: 'Register content folder(s)',
description: (
<>
@@ -136,7 +146,15 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
),
status: settings.contentFolders && settings.contentFolders.length > 0 ? Status.Completed : Status.NotStarted
},
{
id: `welcome-import`,
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
},
{
id: `welcome-show-dashboard`,
name: 'Show the dashboard',
description: <>Once all actions are completed, the dashboard can be loaded.</>,
status: (settings.initialized && settings.contentFolders && settings.contentFolders.length > 0) ? Status.Active : Status.NotStarted,
@@ -154,7 +172,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
<nav aria-label="Progress">
<ol role="list">
{steps.map((step, stepIdx) => (
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pb-10' : ''} relative`}>
<li key={step.id} className={`${stepIdx !== steps.length - 1 ? 'pb-10' : ''} relative`} data-test={step.id}>
<Step name={step.name} description={step.description} status={step.status} showLine={stepIdx !== steps.length - 1} onClick={step.onClick} />
</li>
))}

View File

@@ -0,0 +1,103 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import { ArrowCircleUpIcon, ArrowUpIcon, 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 onMove = useCallback(() => {
Messenger.send(DashboardMessage.moveTaxonomy, {
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-400 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-400 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-400 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-400 dark:hover:text-whisper-600'
type={`button`}
title={`Move to another taxonomy type`}
onClick={onMove}>
<ArrowCircleUpIcon className={`w-4 h-4`} aria-hidden={true} />
<span className='sr-only'>Move to another taxonomy type</span>
</button>
<button
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 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>
);
};

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { Page } from '../../models';
import { SettingsSelector } from '../../state';
import { useRecoilValue } from 'recoil';
import { getTaxonomyField } from '../../../helpers/getTaxonomyField';
import { useNavigate } from 'react-router-dom';
import { routePaths } from '../..';
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 navigate = useNavigate();
const total: number | undefined = useMemo(() => {
if (!taxonomy || !value || !pages || !settings?.contentTypes) {
return undefined;
}
return pages.filter(page => {
if (taxonomy === "tags") {
return (page.fmTags || []).includes(value);
} else if (taxonomy === "categories") {
return (page.fmCategories || []).includes(value);
}
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]);
const onNavigate = useCallback(() => {
if (total) {
navigate(`${routePaths.contents}?taxonomy=${taxonomy}&value=${value}`);
}
}, [total, navigate]);
if (taxonomy === "tags" || taxonomy === "categories") {
return (
<button
className={total ? `text-teal-900 hover:text-teal-600 font-bold` : ``}
title={total ? `Show contents with ${value} in ${taxonomy}` : ``}
onClick={onNavigate}>
{total || `-`}
</button>
);
}
return (
<span>
{total || `-`}
</span>
);
};

View File

@@ -0,0 +1,181 @@
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 '../../../helpers/getTaxonomyField';
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) {
let values: string[] = [];
if (taxonomy === "tags") {
values = page.fmTags || [];
} else if (taxonomy === "categories") {
values = page.fmCategories || [];
} else {
const contentType = settings.contentTypes.find(ct => ct.name === page.fmContentType);
if (!contentType) {
return false;
}
let fieldName = getTaxonomyField(taxonomy, contentType);
if (fieldName && page[fieldName]) {
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 flex flex-col h-full overflow-hidden`}>
<div className={`flex w-full justify-between flex-shrink-0`}>
<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 pb-6 -mr-4 pr-4 flex flex-col flex-grow overflow-auto">
<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">Count</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>
);
};

View File

@@ -0,0 +1,97 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import { ChevronRightIcon, DownloadIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { TelemetryEvent } from '../../../constants';
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 settings = useRecoilValue(SettingsSelector);
const [ taxonomySettings, setTaxonomySettings ] = useState<TaxonomyData>();
const [ selectedTaxonomy, setSelectedTaxonomy ] = useState<string | null>(`tags`);
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
});
}, []);
return (
<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 h-full overflow-hidden`}>
<TaxonomyManager
data={taxonomySettings}
taxonomy={selectedTaxonomy}
pages={pages} />
</div>
</div>
<SponsorMsg beta={settings?.beta} version={settings?.versionInfo} isBacker={settings?.isBacker} />
</PageLayout>
);
};

View File

@@ -0,0 +1 @@
export * from './TaxonomyView';

View File

@@ -0,0 +1,18 @@
import { StopIcon } from '@heroicons/react/outline';
import * as React from 'react';
export interface IUnknownViewProps {}
export const UnknownView: React.FunctionComponent<IUnknownViewProps> = (props: React.PropsWithChildren<IUnknownViewProps>) => {
return (
<div className={`w-full h-full flex items-center justify-center`}>
<div className='flex flex-col items-center text-gray-500 dark:text-whisper-900'>
<StopIcon className='w-32 h-32' />
<p className='text-3xl mt-2'>View does not exist</p>
<p className='text-xl mt-4'>
You seem to have ended up on a view that doesn't exist. Please re-open the dashboard.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './UnknownView';

View File

@@ -33,6 +33,8 @@ export default function useMessages() {
setView(NavigationType.Contents);
} else if (message.data?.type === NavigationType.Data) {
setView(NavigationType.Data);
} else if (message.data?.type === NavigationType.Taxonomy) {
setView(NavigationType.Taxonomy);
} else if (message.data?.type === NavigationType.Snippets) {
setView(NavigationType.Snippets);
}

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
import { SortOption } from '../constants/SortOption';
import { Tab } from '../constants/Tab';
import { Page } from '../models/Page';
import Fuse from 'fuse.js';
import { useRecoilState, useRecoilValue } from 'recoil';
import { CategorySelector, FolderSelector, SearchSelector, SettingsSelector, SortingAtom, TabInfoAtom, TabSelector, TagSelector } from '../state';
import { SortOrder, SortType } from '../../models';
@@ -46,45 +45,6 @@ export default function usePages(pages: Page[]) {
});
}
const draftTypes = Object.assign({}, tabInfo);
draftTypes[Tab.All] = pagesToShow.length;
// Filter by draft status
if (draftField && draftField.type === 'choice') {
const draftChoices = settings?.draftField?.choices;
for (const choice of (draftChoices || [])) {
if (choice) {
draftTypes[choice] = pagesToShow.filter(page => page.fmDraft === choice).length;
}
}
if (tab !== Tab.All) {
pagesToShow = pagesToShow.filter(page => page.fmDraft === tab);
} else {
pagesToShow = searchedPages;
}
} else {
// Draft field is a boolean field
const draftFieldName = draftField?.name || "draft";
const drafts = pagesToShow.filter(page => page[draftFieldName] == true || page[draftFieldName] === "true");
const published = pagesToShow.filter(page => page[draftFieldName] == false || page[draftFieldName] === "false" || typeof page[draftFieldName] === "undefined");
draftTypes[Tab.Draft] = draftField?.invert ? published.length : drafts.length;
draftTypes[Tab.Published] = draftField?.invert ? drafts.length : published.length;
if (tab === Tab.Published) {
pagesToShow = draftField?.invert ? drafts : published;
} else if (tab === Tab.Draft) {
pagesToShow = draftField?.invert ? published : drafts;
} else {
pagesToShow = searchedPages;
}
}
// Set the tab information
setTabInfo(draftTypes);
// Sort the pages
let pagesSorted: Page[] = Object.assign([], pagesToShow);
if (!search) {
@@ -131,6 +91,47 @@ export default function usePages(pages: Page[]) {
pagesSorted = pagesSorted.filter(page => page.fmCategories && page.fmCategories.includes(category));
}
// Process the tab data
const draftTypes = Object.assign({}, tabInfo);
draftTypes[Tab.All] = pagesSorted.length;
// Filter by draft status
if (draftField && draftField.type === 'choice') {
const draftChoices = settings?.draftField?.choices;
for (const choice of (draftChoices || [])) {
if (choice) {
draftTypes[choice] = pagesSorted.filter(page => page.fmDraft === choice).length;
}
}
if (tab !== Tab.All) {
pagesSorted = pagesSorted.filter(page => page.fmDraft === tab);
} else {
pagesSorted = pagesSorted;
}
} else {
// Draft field is a boolean field
const draftFieldName = draftField?.name || "draft";
const drafts = pagesSorted.filter(page => page[draftFieldName] == true || page[draftFieldName] === "true");
const published = pagesSorted.filter(page => page[draftFieldName] == false || page[draftFieldName] === "false" || typeof page[draftFieldName] === "undefined");
draftTypes[Tab.Draft] = draftField?.invert ? published.length : drafts.length;
draftTypes[Tab.Published] = draftField?.invert ? drafts.length : published.length;
if (tab === Tab.Published) {
pagesSorted = draftField?.invert ? drafts : published;
} else if (tab === Tab.Draft) {
pagesSorted = draftField?.invert ? published : drafts;
} else {
pagesSorted = pagesSorted;
}
}
// Set the tab information
setTabInfo(draftTypes);
// Set the pages
setPageItems(pagesSorted);
}, [ settings, tab, folder, search, tag, category, sorting, tabInfo ]);
@@ -165,7 +166,7 @@ export default function usePages(pages: Page[]) {
} else {
processPages(searchedPages);
}
}, [ settings?.draftField, pages, sorting, search, tab ]);
}, [ settings?.draftField, pages, sorting, search, tab, tag, category, folder ]);
useEffect(() => {
Messenger.listen(searchListener);

View File

@@ -1,10 +1,11 @@
import * as React from "react";
import { render } from "react-dom";
import { RecoilRoot } from "recoil";
import { Dashboard } from "./components/Dashboard";
import { App } from "./components/App";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import { SENTRY_LINK } from "../constants";
import { MemoryRouter } from "react-router-dom";
import './styles.css';
import { Preview } from "./components/Preview";
@@ -14,6 +15,15 @@ declare const acquireVsCodeApi: <T = unknown>() => {
postMessage: (msg: unknown) => void;
};
export const routePaths: { [name: string]: string } = {
welcome: "/welcome",
contents: "/contents",
media: "/media",
snippets: "/snippets",
data: "/data",
taxonomy: "/taxonomy",
};
const elm = document.querySelector("#app");
if (elm) {
const welcome = elm?.getAttribute("data-showWelcome");
@@ -37,7 +47,15 @@ if (elm) {
if (type === "preview") {
render(<Preview url={url} />, elm);
} else {
render(<RecoilRoot><Dashboard showWelcome={!!welcome} /></RecoilRoot>, elm);
render((
<RecoilRoot>
<MemoryRouter
initialEntries={Object.keys(routePaths).map((key: string) => routePaths[key]) as string[]}
initialIndex={1}>
<App showWelcome={!!welcome} />
</MemoryRouter>
</RecoilRoot>
), elm);
}
}

View File

@@ -3,4 +3,5 @@ export enum NavigationType {
Media = "media",
Data = "data",
Snippets = "snippets",
Taxonomy = "taxonomy",
}

View File

@@ -11,6 +11,7 @@ export interface Page {
fmPreviewImage: string;
fmTags: string[];
fmCategories: string[];
fmContentType: string;
title: string;
slug: string;

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;
@@ -41,6 +42,7 @@ export interface ContentsViewState {
sorting: SortingOption | null | undefined;
defaultSorting: string | null | undefined;
tags: string | null | undefined;
templatesEnabled: boolean | null | undefined;
}
export interface MediaViewState extends ContentsViewState {

View File

@@ -79,6 +79,11 @@ export async function activate(context: vscode.ExtensionContext) {
Telemetry.send(TelemetryEvent.openDataDashboard);
Dashboard.open({ type: NavigationType.Data });
}));
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardTaxonomy, (data?: DashboardData) => {
Telemetry.send(TelemetryEvent.openTaxonomyDashboard);
Dashboard.open({ type: NavigationType.Taxonomy });
}));
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardClose, (data?: DashboardData) => {
Telemetry.send(TelemetryEvent.closeDashboard);
@@ -126,16 +131,7 @@ export async function activate(context: vscode.ExtensionContext) {
const setLastModifiedDate = vscode.commands.registerCommand(COMMAND_NAME.setLastModifiedDate, Article.setLastModifiedDate);
const generateSlug = vscode.commands.registerCommand(COMMAND_NAME.generateSlug, Article.generateSlug);
const createFromTemplate = vscode.commands.registerCommand(COMMAND_NAME.createFromTemplate, (folder: vscode.Uri) => {
const folderPath = Folders.getFolderPath(folder);
if (folderPath) {
Template.create(folderPath);
}
});
let createTemplate = vscode.commands.registerCommand(COMMAND_NAME.createTemplate, Template.generate);
const generateSlug = vscode.commands.registerCommand(COMMAND_NAME.generateSlug, Article.updateSlug);
subscriptions.push(
vscode.commands.registerCommand(COMMAND_NAME.initTemplate, () => Project.createSampleTemplate(true))
@@ -154,6 +150,20 @@ export async function activate(context: vscode.ExtensionContext) {
const createFolder = vscode.commands.registerCommand(COMMAND_NAME.createFolder, Folders.addMediaFolder);
/**
* Template creation
*/
const createTemplate = vscode.commands.registerCommand(COMMAND_NAME.createTemplate, Template.generate);
const createFromTemplate = vscode.commands.registerCommand(COMMAND_NAME.createFromTemplate, (folder: vscode.Uri) => {
const folderPath = Folders.getFolderPath(folder);
if (folderPath) {
Template.create(folderPath);
}
});
/**
* Content creation
*/
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);

View File

@@ -1,8 +1,8 @@
import { ModeListener } from './../listeners/general/ModeListener';
import { PagesListener } from './../listeners/dashboard';
import { ArticleHelper, Settings } from ".";
import { FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_FRAMEWORK_ID, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants";
import { ContentType as IContentType, DraftField, Field } from '../models';
import { FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_FRAMEWORK_ID, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_TAXONOMY_FIELD_GROUPS, TelemetryEvent } from "../constants";
import { ContentType as IContentType, DraftField, Field, FieldGroup, FieldType } from '../models';
import { Uri, commands, window } from 'vscode';
import { Folders } from "../commands/Folders";
import { Questions } from "./Questions";
@@ -12,6 +12,7 @@ import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType";
import { Telemetry } from './Telemetry';
import { processKnownPlaceholders } from './PlaceholderHelper';
import { basename } from 'path';
import { ParsedFrontMatter } from '../parsers';
export class ContentType {
@@ -271,6 +272,147 @@ export class ContentType {
ArticleHelper.update(editor!, content);
}
/**
* Retrieve the field value
* @param data
* @param parents
* @returns
*/
public static getFieldValue(data: any, parents: string[]): string[] {
let fieldValue = [];
let crntPageData = data;
for (let i = 0; i < parents.length; i++) {
const crntField = parents[i];
if (i === parents.length - 1) {
fieldValue = crntPageData[crntField];
} else {
if (!crntPageData[crntField]) {
continue;
}
crntPageData = crntPageData[crntField];
}
}
return fieldValue;
}
/**
* Set the field value
* @param data
* @param parents
* @returns
*/
public static setFieldValue(data: any, parents: string[], value: any) {
let crntPageData = data;
for (let i = 0; i < parents.length; i++) {
const crntField = parents[i];
if (i === parents.length - 1) {
crntPageData[crntField] = value;
} else {
if (!crntPageData[crntField]) {
continue;
}
crntPageData = crntPageData[crntField];
}
}
return data;
}
/**
* Find the field by its type
* @param fields
* @param type
* @param parents
* @returns
*/
public static findFieldByType(fields: Field[], type: FieldType, parents: string[] = []): string[] {
for (const field of fields) {
if (field.type === type) {
parents = [...parents, field.name];
return parents;
} else if (field.type === "fields" && field.fields) {
const subFields = this.findFieldByType(field.fields, type, parents);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
}
}
return parents;
}
/**
* Find the preview field in the fields
* @param ctFields
* @param parents
* @returns
*/
public static findPreviewField(ctFields: Field[], parents: string[] = []): string[] {
for (const field of ctFields) {
if (field.isPreviewImage && field.type === "image") {
parents = [...parents, field.name];
return parents;
} else if (field.type === "fields" && field.fields) {
const subFields = this.findPreviewField(field.fields);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
} else if (field.type === "block") {
const subFields = this.findPreviewInBlockField(field);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
}
}
return parents;
}
/**
* Look for the preview image in the block field
* @param field
* @param parents
* @returns
*/
private static findPreviewInBlockField(field: Field) {
const groups = field.fieldGroup && Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
if (!groups) {
return [];
}
const blocks = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
if (!blocks) {
return [];
}
let found = false;
for (const group of groups) {
const block = blocks.find(block => block.id === group);
if (!block) {
continue;
}
let newParents: string[] = [];
if (!found) {
newParents = this.findPreviewField(block?.fields, []);
}
if (newParents.length > 0) {
found = true;
return newParents;
}
}
return [];
}
/**
* Generate the fields from the data
* @param data
@@ -362,6 +504,13 @@ export class ContentType {
return;
}
let templatePath = contentType.template;
let templateData: ParsedFrontMatter | null = null;
if (templatePath) {
templatePath = Folders.getAbsFilePath(templatePath);
templateData = ArticleHelper.getFrontMatterByPath(templatePath);
}
let newFilePath: string | undefined = ArticleHelper.createContent(contentType, folderPath, titleValue);
if (!newFilePath) {
return;
@@ -377,7 +526,7 @@ export class ContentType {
}
}
let data: any = this.processFields(contentType, titleValue, {});
let data: any = this.processFields(contentType, titleValue, templateData?.data || {});
data = ArticleHelper.updateDates(Object.assign({}, data));
@@ -385,7 +534,7 @@ export class ContentType {
data['type'] = contentType.name;
}
const content = ArticleHelper.stringifyFrontMatter(``, data);
const content = ArticleHelper.stringifyFrontMatter(templateData?.content || ``, data);
writeFileSync(newFilePath, content, { encoding: "utf8" });

View File

@@ -2,8 +2,7 @@ import { basename, join } from "path";
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_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, SETTING_TEMPLATES_ENABLED } from "../constants";
import { DashboardViewType, SortingOption, Settings as ISettings } from "../dashboardWebView/models";
import { CustomScript, DraftField, Snippets, SortingSetting, TaxonomyType } from "../models";
import { DataFile } from "../models/DataFile";
@@ -24,10 +23,11 @@ export class DashboardSettings {
return {
beta: ext.isBetaVersion(),
wsFolder: wsFolder ? wsFolder.fsPath : '',
staticFolder: Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER),
staticFolder: Folders.getStaticFolderRelativePath(),
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"),
@@ -46,6 +46,7 @@ export class DashboardSettings {
sorting: await ext.getState<SortingOption | undefined>(ExtensionState.Dashboard.Contents.Sorting, "workspace"),
defaultSorting: Settings.get<string>(SETTING_CONTENT_SORTING_DEFAULT),
tags: Settings.get<string>(SETTING_DASHBOARD_CONTENT_TAGS),
templatesEnabled: Settings.get<boolean>(SETTING_TEMPLATES_ENABLED)
},
media: {
sorting: await ext.getState<SortingOption | undefined>(ExtensionState.Dashboard.Media.Sorting, "workspace"),

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;
}
}

View File

@@ -4,8 +4,6 @@ import { dirname, join } from "path";
import { Field } from '../models';
import { existsSync } from 'fs';
import { Folders } from '../commands/Folders';
import { Settings } from './SettingsHelper';
import { SETTING_CONTENT_STATIC_FOLDER } from '../constants';
import { parseWinPath } from './parseWinPath';
export class ImageHelper {
@@ -51,7 +49,7 @@ export class ImageHelper {
*/
public static relToAbs(filePath: string, value: string) {
const wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
const staticFolder = Folders.getStaticFolderRelativePath();
const staticPath = join(parseWinPath(wsFolder?.fsPath || ""), staticFolder || "", value);
const contentFolderPath = filePath ? join(dirname(filePath), value) : null;
@@ -73,7 +71,7 @@ export class ImageHelper {
*/
public static absToRel(imgValue: string) {
const wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
const staticFolder = Folders.getStaticFolderRelativePath();
let relPath = imgValue || "";
if (imgValue) {

View File

@@ -1,7 +1,7 @@
import { decodeBase64, Extension, MediaLibrary, Notifications, parseWinPath, Settings, Sorting } from ".";
import { Dashboard } from "../commands/Dashboard";
import { Folders } from "../commands/Folders";
import { DEFAULT_CONTENT_TYPE, ExtensionState, HOME_PAGE_NAVIGATION_ID, SETTING_CONTENT_STATIC_FOLDER, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants";
import { DEFAULT_CONTENT_TYPE, ExtensionState, HOME_PAGE_NAVIGATION_ID, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants";
import { SortingOption } from "../dashboardWebView/models";
import { MediaInfo, MediaPaths, SortOrder, SortType } from "../models";
import { basename, extname, join, parse, dirname, relative } from "path";
@@ -27,7 +27,7 @@ export class MediaHelpers {
*/
public static async getMedia(page: number = 0, requestedFolder: string = '', sort: SortingOption | null = null) {
const wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
const staticFolder = Folders.getStaticFolderRelativePath();
const contentFolders = Folders.get();
const viewData = Dashboard.viewData;
let selectedFolder = requestedFolder;
@@ -210,7 +210,7 @@ export class MediaHelpers {
public static async saveFile({fileName, contents, folder}: { fileName: string; contents: string; folder: string | null }) {
if (fileName && contents) {
const wsFolder = Folders.getWorkspaceFolder();
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
const staticFolder = Folders.getStaticFolderRelativePath();
const wsPath = wsFolder ? wsFolder.fsPath : "";
let absFolderPath = join(wsPath, staticFolder || "");
@@ -271,10 +271,6 @@ export class MediaHelpers {
*/
public static async insertMediaToMarkdown(data: any) {
if (data?.file && data?.relPath) {
if (!data?.position) {
await commands.executeCommand(`workbench.view.extension.frontmatter-explorer`);
}
await EditorHelper.showFile(data.file);
Dashboard.resetViewData();

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
*/

View File

@@ -0,0 +1,430 @@
import { getTaxonomyField } from './getTaxonomyField';
import { EXTENSION_NAME, SETTING_TAXONOMY_CUSTOM } from "../constants";
import { CustomTaxonomy, TaxonomyType, ContentType as IContentType } 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';
import { ContentType } from './ContentType';
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 fieldNames: string[] = this.getFieldsHierarchy(taxonomyType, contentType);
if (fieldNames.length > 0 && article && article.data) {
const { data } = article;
let taxonomies: string[] = ContentType.getFieldValue(data, fieldNames);
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);
}
const newTaxValue = [...new Set(taxonomies)].sort();
ContentType.setFieldValue(data, fieldNames, newTaxValue);
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.`);
}
});
}
/**
* Move a taxonomy value to another taxonomy type
* @param data
* @returns
*/
public static async move(data: { type: string, value: string }) {
const { type, value } = data;
const customTaxs = Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM, true) || [];
let options = [
"tags",
"categories",
...customTaxs.map(t => t.id)
];
options = options.filter(o => o !== type);
const answer = await window.showQuickPick(options, {
title: `Move the "${value}" to another type`,
placeHolder: `Select the type to move to`,
ignoreFocusOut: true
});
if (!answer) {
return;
}
const oldType = this.getTypeFromString(type);
const newType = this.getTypeFromString(answer);
window.withProgress({
location: ProgressLocation.Notification,
title: `${EXTENSION_NAME}: Moving "${value}" from ${type} to "${answer}".`,
cancellable: false
}, async (progress) => {
// Retrieve all the markdown files
const allFiles = await FilesHelper.getAllFiles();
if (!allFiles) {
return;
}
// 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 oldFieldNames: string[] = this.getFieldsHierarchy(oldType, contentType);
let newFieldNames: string[] = this.getFieldsHierarchy(newType, contentType, true);
if (oldFieldNames.length > 0 && newFieldNames.length > 0 && article && article.data) {
const { data } = article;
let oldTaxonomies: string[] = ContentType.getFieldValue(data, oldFieldNames) || [];
let newTaxonomies: string[] = ContentType.getFieldValue(data, newFieldNames) || [];
if (oldTaxonomies && oldTaxonomies.length > 0) {
const idx = oldTaxonomies.findIndex(o => o === value);
if (idx !== -1) {
newTaxonomies.push(value);
const newTaxonomiesValues = [...new Set(newTaxonomies)].sort();
ContentType.setFieldValue(data, newFieldNames, newTaxonomiesValues);
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(newType, value, value);
await this.process("delete", oldType, value);
Notifications.info(`Move completed.`);
});
}
/**
* Retrieve the fields for the taxonomy field
* @returns
*/
private static getFieldsHierarchy(taxonomyType: TaxonomyType | string, contentType: IContentType, fallback: boolean = false): string[] {
let fieldNames: string[] = [];
if (taxonomyType === TaxonomyType.Tag) {
fieldNames = ContentType.findFieldByType(contentType.fields, "tags");
} else if (taxonomyType === TaxonomyType.Category) {
fieldNames = ContentType.findFieldByType(contentType.fields, "categories");
} else {
const taxFieldName = getTaxonomyField(taxonomyType, contentType);
fieldNames = taxFieldName ? [taxFieldName] : [];
}
if (fallback && fieldNames.length === 0) {
let taxFieldName;
if (taxonomyType === TaxonomyType.Tag) {
taxFieldName = getTaxonomyField("tags", contentType);
} else if (taxonomyType === TaxonomyType.Category) {
taxFieldName = getTaxonomyField("categories", contentType);
}
if (taxFieldName) {
fieldNames = [taxFieldName];
}
}
return fieldNames;
}
/**
* 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;
}
}
}

View File

@@ -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;
}

View File

@@ -23,9 +23,11 @@ export * from './SlugHelper';
export * from './SnippetParser';
export * from './Sorting';
export * from './StringHelpers';
export * from './TaxonomyHelper';
export * from './Telemetry';
export * from './decodeBase64Image';
export * from './getNonce';
export * from './getTaxonomyField';
export * from './isValidFile';
export * from './openFileInEditor';
export * from './parseWinPath';

View File

@@ -1,9 +1,10 @@
import { workspace } from 'vscode';
import { DataFile } from './../../models/DataFile';
import { DashboardMessage } from "../../dashboardWebView/DashboardMessage";
import { BaseListener } from "./BaseListener";
import { DashboardCommand } from '../../dashboardWebView/DashboardCommand';
import { Folders } from '../../commands/Folders';
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
import { dirname } from 'path';
import * as yaml from 'js-yaml';
import { DataFileHelper } from '../../helpers';
@@ -30,6 +31,10 @@ export class DataListener extends BaseListener {
}
}
/**
* Process the data update
* @param msgData
*/
private static processDataUpdate(msgData: any) {
const { file, fileType, entries } = msgData as { file: string, fileType: string, entries: any[] };
@@ -41,11 +46,17 @@ export class DataListener extends BaseListener {
}
}
const fileContent = readFileSync(absPath, 'utf8');
// check if file content ends with newline
const newFileContent = fileContent.endsWith('\n');
const insertFinalNewLine = newFileContent || workspace.getConfiguration().get('files.insertFinalNewline');
if (fileType === 'yaml') {
const yamlData = yaml.safeDump(entries);
writeFileSync(absPath, yamlData, 'utf8');
writeFileSync(absPath, insertFinalNewLine ? `${yamlData}\n` : yamlData, 'utf8');
} else {
writeFileSync(absPath, JSON.stringify(entries, null, 2));
const jsonData = JSON.stringify(entries, null, 2);
writeFileSync(absPath, insertFinalNewLine ? `${jsonData}\n` : jsonData, 'utf8');
}
this.processDataFile(msgData);

View File

@@ -1,10 +1,11 @@
import { DEFAULT_CONTENT_TYPE_NAME } from './../../constants/ContentType';
import { isValidFile } from '../../helpers/isValidFile';
import { existsSync, unlinkSync } from "fs";
import { basename, dirname, join } from "path";
import { commands, FileSystemWatcher, RelativePattern, TextDocument, Uri, workspace } from "vscode";
import { Dashboard } from "../../commands/Dashboard";
import { Folders } from "../../commands/Folders";
import { COMMAND_NAME, DefaultFields, ExtensionState, SETTING_CONTENT_STATIC_FOLDER, SETTING_SEO_DESCRIPTION_FIELD, SETTING_TAXONOMY_FIELD_GROUPS } from "../../constants";
import { COMMAND_NAME, DefaultFields, ExtensionState, SETTING_SEO_DESCRIPTION_FIELD } from "../../constants";
import { DashboardCommand } from "../../dashboardWebView/DashboardCommand";
import { DashboardMessage } from "../../dashboardWebView/DashboardMessage";
import { Page } from "../../dashboardWebView/models";
@@ -13,7 +14,6 @@ import { ContentType } from "../../helpers/ContentType";
import { DateHelper } from "../../helpers/DateHelper";
import { Notifications } from "../../helpers/Notifications";
import { BaseListener } from "./BaseListener";
import { Field, FieldGroup, FieldType } from '../../models';
import { DataListener } from '../panel';
import Fuse from 'fuse.js';
@@ -91,7 +91,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));
@@ -169,7 +169,7 @@ export class PagesListener extends BaseListener {
try {
const page = this.processPageContent(file.filePath, file.mtime, file.fileName, folder.title);
if (page) {
if (page && !pages.find(p => p.fmFilePath === page.fmFilePath)) {
pages.push(page);
}
@@ -267,7 +267,7 @@ export class PagesListener extends BaseListener {
const modifiedField = ArticleHelper.getModifiedDateField(article) || null;
const modifiedFieldValue = modifiedField && article?.data[modifiedField] ? DateHelper.tryParse(article?.data[modifiedField])?.getTime() : undefined;
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
const staticFolder = Folders.getStaticFolderRelativePath();
const page: Page = {
...article.data,
@@ -282,6 +282,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,8 +293,11 @@ export class PagesListener extends BaseListener {
};
const contentType = ArticleHelper.getContentType(article.data);
if (contentType) {
page.fmContentType = contentType.name;
}
let previewFieldParents = this.findPreviewField(contentType.fields);
let previewFieldParents = ContentType.findPreviewField(contentType.fields);
if (previewFieldParents.length === 0) {
const previewField = contentType.fields.find(field => field.type === "image" && field.name === "preview");
if (previewField) {
@@ -301,15 +305,11 @@ export class PagesListener extends BaseListener {
}
}
let tagParents = this.findFieldByType(contentType.fields, "tags");
if (tagParents.length !== 0) {
page.fmTags = this.getFieldValue(article.data, tagParents);
}
let tagParents = ContentType.findFieldByType(contentType.fields, "tags");
page.fmTags = ContentType.getFieldValue(article.data, tagParents.length !== 0 ? tagParents : ["tags"]);
let categoryParents = this.findFieldByType(contentType.fields, "categories");
if (categoryParents.length !== 0) {
page.fmCategories = this.getFieldValue(article.data, categoryParents);
}
let categoryParents = ContentType.findFieldByType(contentType.fields, "categories");
page.fmCategories = ContentType.getFieldValue(article.data, categoryParents.length !== 0 ? categoryParents : ["categories"]);
// Check if parent fields were retrieved, if not there was no image present
if (previewFieldParents.length > 0) {
@@ -375,119 +375,4 @@ export class PagesListener extends BaseListener {
return;
}
/**
* Retrieve the field value
* @param data
* @param parents
* @returns
*/
private static getFieldValue(data: any, parents: string[]): string[] {
let fieldValue = [];
let crntPageData = data;
for (let i = 0; i < parents.length; i++) {
const crntField = parents[i];
if (i === parents.length - 1) {
fieldValue = crntPageData[crntField];
} else {
if (!crntPageData[crntField]) {
continue;
}
crntPageData = crntPageData[crntField];
}
}
return fieldValue;
}
/**
* Find the field by its type
* @param fields
* @param type
* @param parents
* @returns
*/
private static findFieldByType(fields: Field[], type: FieldType, parents: string[] = []) {
for (const field of fields) {
if (field.type === type) {
parents = [...parents, field.name];
return parents;
} else if (field.type === "fields" && field.fields) {
const subFields = this.findPreviewField(field.fields);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
}
}
return parents;
}
/**
* Find the preview field in the fields
* @param ctFields
* @param parents
* @returns
*/
private static findPreviewField(ctFields: Field[], parents: string[] = []): string[] {
for (const field of ctFields) {
if (field.isPreviewImage && field.type === "image") {
parents = [...parents, field.name];
return parents;
} else if (field.type === "fields" && field.fields) {
const subFields = this.findPreviewField(field.fields);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
} else if (field.type === "block") {
const subFields = this.findPreviewInBlockField(field);
if (subFields.length > 0) {
return [...parents, field.name, ...subFields];
}
}
}
return parents;
}
/**
* Look for the preview image in the block field
* @param field
* @param parents
* @returns
*/
private static findPreviewInBlockField(field: Field) {
const groups = field.fieldGroup && Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
if (!groups) {
return [];
}
const blocks = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
if (!blocks) {
return [];
}
let found = false;
for (const group of groups) {
const block = blocks.find(block => block.id === group);
if (!block) {
continue;
}
let newParents: string[] = [];
if (!found) {
newParents = this.findPreviewField(block?.fields, []);
}
if (newParents.length > 0) {
found = true;
return newParents;
}
}
return [];
}
}

View File

@@ -0,0 +1,57 @@
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, TaxonomyHelper } from "../../helpers";
import { CustomTaxonomy } from "../../models";
import { BaseListener } from "./BaseListener";
export class TaxonomyListener extends BaseListener {
/**
* Process the messages for the dashboard views
* @param msg
*/
public static process(msg: { command: DashboardMessage, data: any }) {
super.process(msg);
switch(msg.command) {
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.moveTaxonomy:
TaxonomyHelper.move(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;
}
}
private static async getData() {
// Retrieve the tags, categories and custom taxonomy
const taxonomyData = {
tags: Settings.get<string[]>(SETTING_TAXONOMY_TAGS) || [],
categories: Settings.get<string[]>(SETTING_TAXONOMY_CATEGORIES) || [],
customTaxonomy: Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM) || []
};
this.sendMsg(DashboardCommand.setTaxonomyData, taxonomyData);
}
}

View File

@@ -7,3 +7,4 @@ export * from './PagesListener';
export * from './SettingsListener';
export * from './SnippetListener';
export * from './TelemetryListener';
export * from './TaxonomyListener';

View File

@@ -1,4 +1,5 @@
import { Article } from "../../commands";
import { Command } from "../../panelWebView/Command";
import { CommandToCode } from "../../panelWebView/CommandToCode";
import { BaseListener } from "./BaseListener";
@@ -14,7 +15,10 @@ export class ArticleListener extends BaseListener {
switch(msg.command) {
case CommandToCode.updateSlug:
Article.generateSlug();
Article.updateSlug();
break;
case CommandToCode.generateSlug:
this.generateSlug(msg.data);
break;
case CommandToCode.updateLastMod:
Article.setLastModifiedDate();
@@ -24,4 +28,15 @@ export class ArticleListener extends BaseListener {
break;
}
}
/**
* Generate a slug
* @param title
*/
private static generateSlug(title: string) {
const slug = Article.generateSlug(title);
if (slug) {
this.sendMsg(Command.updatedSlug, slug)
}
}
}

View File

@@ -107,17 +107,20 @@ export class DataListener extends BaseListener {
if (keys.length > 0 && contentTypes && wsFolder) {
// Get the current content type
const contentType = ArticleHelper.getContentType(updatedMetadata);
let slugField;
if (contentType) {
ImageHelper.processImageFields(updatedMetadata, contentType.fields);
slugField = contentType.fields.find((f) => f.type === "slug");
}
}
// Check slug
if (!slugField && !updatedMetadata[DefaultFields.Slug]) {
const slug = Article.getSlug();
// Check slug
if (!updatedMetadata[DefaultFields.Slug]) {
const slug = Article.getSlug();
if (slug) {
updatedMetadata[DefaultFields.Slug] = slug;
if (slug) {
updatedMetadata[DefaultFields.Slug] = slug;
}
}
}

View File

@@ -3,4 +3,5 @@ export interface ContentFolder {
path: string;
excludeSubdir?: boolean;
previewPath?: string;
}

View File

@@ -46,9 +46,10 @@ export interface ContentType {
fileType?: "md" | "mdx" | string;
previewPath?: string | null;
pageBundle?: boolean;
template?: string;
}
export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file" | "dataFile";
export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file" | "dataFile" | "list" | "slug";
export interface Field {
title?: string;
@@ -67,6 +68,7 @@ export interface Field {
dataType?: string | string[];
taxonomyLimit?: number;
fileExtensions?: string[];
editable?: boolean;
// Date fields
isPublishDate?: boolean;

View File

@@ -0,0 +1,8 @@
import { CustomTaxonomy } from ".";
export interface TaxonomyData {
tags: string[];
categories: string[];
customTaxonomy: CustomTaxonomy[];
}

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';

View File

@@ -10,4 +10,5 @@ export enum Command {
sendMediaUrl = "sendMediaUrl",
updatePlaceholder = "updatePlaceholder",
dataFileEntries = "dataFileEntries",
updatedSlug = "updatedSlug",
}

View File

@@ -38,4 +38,5 @@ export enum CommandToCode {
addMissingFields = "add-missing-fields",
setContentType = "set-content-type",
getDataEntries = "get-data-entries",
generateSlug = "generate-slug",
}

View File

@@ -12,7 +12,7 @@ export interface IContentTypeValidatorProps {
metadata: IMetadata
}
const fieldsToIgnore = [`filePath`, `articleDetails`, `slug`];
const fieldsToIgnore = [`filePath`, `articleDetails`, `slug`, `keywords`];
export const ContentTypeValidator: React.FunctionComponent<IContentTypeValidatorProps> = ({ fields, metadata}: React.PropsWithChildren<IContentTypeValidatorProps>) => {

View File

@@ -49,8 +49,26 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
}
let parentObj: any = data;
if (parents.length > 1) {
// Get last parent
const lastParent = parents[parents.length - 1];
// Check if the last parent is not the same as the field.
// If it is, then we need to skip it.
if (lastParent !== field.name) {
if (!parentObj[lastParent]) {
parentObj[lastParent] = {};
}
parentObj = parentObj[lastParent];
}
}
// Set the current field to the data object
parentObj[crntField] = crntValue;
// Delete the field group to have it added at the end
delete data["fieldGroup"];
if (selectedIndex !== null && selectedIndex !== undefined) {
dataClone[selectedIndex] = {
@@ -83,6 +101,10 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
onAdd();
}, [value, selectedIndex, onSubmit]);
/**
* On group change
* @param group
*/
const onGroupChange = (group: FieldGroup | null | undefined) => {
setSelectedGroup(group);
@@ -90,7 +112,7 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
// Clear the selected index
onAdd();
}
}
};
/**
* Store the current state + show the form
@@ -123,7 +145,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
onAdd();
};
/**
* Add a new item to the list
*/
const onAdd = useCallback(() => {
setSelectedIndex(null);
setSelectedGroup(null);
@@ -132,6 +156,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
updateSelectionState(undefined);
}, [setSelectedIndex, setSelectedGroup, setSelectedBlockData]);
/**
* Update an item from the list
*/
const onEdit = useCallback((index: number) => {
updateSelectionState(index);
setSelectedIndex(index);
@@ -148,6 +175,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
}
}, [setSelectedIndex, setSelectedBlockData, value]);
/**
* On sort of an item
*/
const onSort = useCallback(({ oldIndex, newIndex }: SortEnd) => {
if (!value || value.length === 0) {
return null;
@@ -193,12 +223,12 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
}
}, []);
if (parentBlock === null) {
if (parentBlock === null && field.type !== "block") {
return null;
}
return (
<div className='json_data__field'>
<div className='block_field'>
<VsLabel>
<div className={`metadata_field__label`}>
@@ -237,7 +267,7 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
selectedIndex: selectedIndex === null ? undefined : selectedIndex
},
onFieldUpdate,
selectedIndex === null ? null : `${field.name}-${selectedGroup?.id}-${selectedIndex}`
`${field.name}-${selectedGroup?.id}-${selectedIndex || 0}`
)
)
}

View File

@@ -0,0 +1,135 @@
import { PencilIcon, TrashIcon, ViewListIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { VsLabel } from '../VscodeComponents';
export interface IListFieldProps {
label: string;
value: string[] | null;
onChange: (value: string | string[]) => void;
}
export const ListField: React.FunctionComponent<IListFieldProps> = ({ label, value, onChange }: React.PropsWithChildren<IListFieldProps>) => {
const [ text, setText ] = React.useState<string | null>("");
const [ list, setList ] = React.useState<string[] | null>(null);
const [ itemToEdit, setItemToEdit ] = React.useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const onTextChange = (txtValue: string) => {
setText(txtValue);
};
const onSaveForm = useCallback(() => {
if (itemToEdit !== null) {
if (list && text) {
const newList = [...(list || [])];
newList[itemToEdit] = text;
setList(newList);
onChange(newList);
}
} else {
if (text) {
const newList = list ? [ ...list, text ] : [ text ];
setList(newList);
onChange(newList);
}
}
onCancelForm();
}, [list, text]);
const onEdit = useCallback((index: number) => {
setItemToEdit(index);
setText(list ? list[index] : "");
inputRef.current?.focus();
}, [list]);
const onCancelForm = useCallback(() => {
setText("");
setItemToEdit(null);
}, []);
const onDelete = useCallback((index: number) => {
if (list) {
const newList = [...list];
newList.splice(index, 1);
setList(newList);
onChange(newList);
}
}, [list]);
useEffect(() => {
if (value) {
if (typeof value === "string") {
setList([value]);
} else {
setList(value);
}
}
}, [value]);
let isValid = true;
return (
<div className={`list_field`}>
<VsLabel>
<div className={`metadata_field__label`}>
<ViewListIcon style={{ width: "16px", height: "16px" }} /> <span style={{ lineHeight: "16px"}}>{label}</span>
</div>
</VsLabel>
<input
ref={inputRef}
className={`metadata_field__input`}
value={text || ""}
onChange={(e) => onTextChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSaveForm();
}
}}
style={{
border: "1px solid var(--vscode-inputValidation-infoBorder)"
}} />
<div className={`list_field__form__buttons`}>
<button
className={`list_field__form__button__save`}
title={`Save`}
onClick={onSaveForm}
disabled={!text}>
{itemToEdit !== null ? `Update` : `Add`}
</button>
<button
className={`list_field__form__button__cancel`}
title={`Cancel`}
onClick={onCancelForm}>
Cancel
</button>
</div>
<ul className='list_field__list'>
{
list && list.length > 0 && list.map((item, index) => (
<li className='list_field__list__item' key={index}>
<div>
{item}
</div>
<div>
<button title='Edit record' className='list_field__list__button list_field__list__button_edit' onClick={() => onEdit(index)}>
<PencilIcon className='list_field__list__button_icon' />
<span className='sr-only'>Edit</span>
</button>
<button title='Delete record' className='list_field__list__button list_field__list__button_delete' onClick={() => onDelete(index)}>
<TrashIcon className='list_field__list__button_icon' />
<span className='sr-only'>Delete</span>
</button>
</div>
</li>
))
}
</ul>
</div>
);
};

View File

@@ -0,0 +1,82 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import { EventData } from '@estruyf/vscode/dist/models';
import {LinkIcon, RefreshIcon} from '@heroicons/react/outline';
import * as React from 'react';
import { useCallback, useEffect } from 'react';
import { Command } from '../../Command';
import { CommandToCode } from '../../CommandToCode';
import { VsLabel } from '../VscodeComponents';
export interface ISlugFieldProps {
label: string;
value: string | null;
titleValue: string | null;
editable?: boolean;
onChange: (txtValue: string) => void;
}
export const SlugField: React.FunctionComponent<ISlugFieldProps> = ({ label, editable, value, titleValue, onChange }: React.PropsWithChildren<ISlugFieldProps>) => {
const [ text, setText ] = React.useState<string | null>(value);
const [ slug, setSlug ] = React.useState<string | null>(value);
useEffect(() => {
if (text !== value) {
setText(value);
}
}, [ value ]);
const onTextChange = (txtValue: string) => {
setText(txtValue);
onChange(txtValue);
};
const updateSlug = () => {
Messenger.send(CommandToCode.updateSlug);
};
const messageListener = useCallback((message: MessageEvent<EventData<any>>) => {
const {command, data} = message.data;
if (command === Command.updatedSlug) {
setSlug(data?.slugWithPrefixAndSuffix);
}
}, [text]);
useEffect(() => {
if (titleValue) {
Messenger.send(CommandToCode.generateSlug, titleValue);
}
}, [titleValue]);
useEffect(() => {
Messenger.listen(messageListener);
return () => {
Messenger.unlisten(messageListener);
}
}, []);
return (
<div className={`metadata_field`}>
<VsLabel>
<div className={`metadata_field__label`}>
<LinkIcon style={{ width: "16px", height: "16px" }} /> <span style={{ lineHeight: "16px"}}>{label}</span>
</div>
</VsLabel>
<div className='metadata_field__slug'>
<input
className={`metadata_field__slug__input`}
value={text || ""}
disabled={editable !== undefined ? !editable : false}
onChange={(e) => onTextChange(e.currentTarget.value)} />
<button
title={slug !== text ? "Update available" : "Generate slug"}
className={`metadata_field__slug__button ${slug !== text ? "metadata_field__slug__button_update" : ""}`}
onClick={updateSlug}>
<RefreshIcon aria-hidden={true} />
</button>
</div>
</div>
);
}

View File

@@ -21,8 +21,10 @@ import { DataFileField } from './DataFileField';
import { DateTimeField } from './DateTimeField';
import { DraftField } from './DraftField';
import { FileField } from './FileField';
import { ListField } from './ListField';
import { NumberField } from './NumberField';
import { PreviewImageField, PreviewImageValue } from './PreviewImageField';
import { SlugField } from './SlugField';
import { TextField } from './TextField';
import { Toggle } from './Toggle';
@@ -360,6 +362,26 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
</FieldBoundary>
);
} else if (field.type === 'list') {
return (
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
<ListField
label={field.title || field.name}
value={fieldValue}
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
</FieldBoundary>
);
} else if (field.type === 'slug') {
return (
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
<SlugField
label={field.title || field.name}
titleValue={metadata.title as string}
value={fieldValue}
editable={field.editable}
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
</FieldBoundary>
);
} else {
console.warn(`Unknown field type: ${field.type}`);
return null;

View File

@@ -1,11 +1,13 @@
.block_field__form {
.block_field__form,
.list_field__form {
background-color: transparent;
border: 1px dashed var(--vscode-button-background);
padding: 1rem;
}
.block_field__form__buttons {
.block_field__form__buttons,
.list_field__form__buttons {
display: flex;
justify-content: space-between;
margin-top: 1rem;
@@ -23,7 +25,8 @@
}
}
.block_field__form__button__save {
.block_field__form__button__save,
.list_field__form__button__save {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
@@ -32,7 +35,8 @@
}
}
.block_field__form__button__cancel {
.block_field__form__button__cancel,
.list_field__form__button__cancel {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
@@ -41,7 +45,9 @@
}
}
.json_data__field {
.json_data__field,
.block_field,
.list_field {
border: 1px dashed var(--vscode-button-secondaryBackground);
color: var(--vscode--settings-headerForeground);
padding: .5rem 1rem;
@@ -120,7 +126,12 @@
}
}
.json_data__list {
.list_field__list {
padding-left: 0;
}
.json_data__list,
.list_field__list {
margin-top: 1rem;
label {
@@ -229,7 +240,8 @@
}
.sortable_item button,
.json_data__list__button {
.json_data__list__button,
.list_field__list__button {
width: auto;
background: none;
color: inherit;
@@ -244,7 +256,8 @@
}
}
.json_data__list__button_icon {
.json_data__list__button_icon,
.list_field__list__button_icon {
height: 1rem;
width: 1rem;
}
@@ -355,6 +368,29 @@ vscode-divider {
}
/* Tags */
.article__tags__input button {
margin: 1px 0;
padding: 0 .5rem;
border-left: 1px solid var(--vscode-inputValidation-infoBorder);
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
display: flex;
align-items: center;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: auto;
&:disabled {
background: none;
filter: brightness(100%);
color: var(--vscode-disabledForeground);
}
}
.article__tags__items {
display: flex;
flex-flow: row wrap;
@@ -420,6 +456,57 @@ vscode-divider {
white-space: nowrap;
}
/* Slug field */
.metadata_field__slug {
position: relative;
width: 100%;
}
.metadata_field__slug input {
padding-right: 2.5rem;
border: 1px solid var(--vscode-inputValidation-infoBorder);
outline: none;
&:disabled {
color: var(--vscode-disabledForeground);
}
}
.metadata_field__slug button {
background: var(--vscode-input-background);
border: none;
border-left: 1px solid var(--vscode-inputValidation-infoBorder);
color: inherit;
outline: none !important;
outline-offset: inherit !important;
margin: 1px;
padding: 0 .5rem;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: auto;
display: flex;
align-items: center;
span {
margin-right: .5rem;
font-size: .8rem;
}
svg {
width: 16px;
height: 16px;
}
&.metadata_field__slug__button_update {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
margin: 0;
}
}
/* Quill changes */
.ql-toolbar.ql-snow,

24
tsconfig.e2e.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"outDir": "e2e/out",
"lib": [
"es6",
"dom"
],
"sourceMap": true,
"strict": false,
"noUnusedLocals": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": [
"e2e/src/**/*"
],
"exclude": [
"node_modules",
"src"
]
}

View File

@@ -19,6 +19,7 @@
"exclude": [
"node_modules",
".vscode-test",
"docs"
"docs",
"e2e"
]
}