From d0b7af5c86fa78b4b9a53ed9dd7bc4b35cc3d855 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 14 Aug 2024 14:06:52 +0200 Subject: [PATCH] Enhancement: Ability to create new data files for a folder #834 --- CHANGELOG.md | 2 + l10n/bundle.l10n.json | 6 + package.json | 347 ++++++++---------- package.nls.json | 2 + src/commands/Folders.ts | 16 + src/dashboardWebView/DashboardMessage.ts | 1 + .../components/DataView/DataView.tsx | 186 +++++++--- .../components/DataView/EmptyView.tsx | 38 +- .../components/Menu/MenuButton.tsx | 15 +- src/dashboardWebView/models/Settings.ts | 2 + src/helpers/DashboardSettings.ts | 13 +- src/helpers/openFileInEditor.ts | 5 + src/listeners/dashboard/DataListener.ts | 85 ++++- src/localization/localization.enum.ts | 24 ++ src/models/DataFolder.ts | 2 + 15 files changed, 482 insertions(+), 262 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ed5c79..766d1023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### 🎨 Enhancements +- [#834](https://github.com/estruyf/vscode-front-matter/issues/834): Added the ability to create new data files for a data folder + ### 🐞 Fixes ## [10.3.0] - 2024-08-13 - [Release notes](https://beta.frontmatter.codes/updates/v10.3.0) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 944f60b5..0629fb5e 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -140,6 +140,9 @@ "dashboard.dataView.dataView.noDataFiles": "No data files found", "dashboard.dataView.dataView.getStarted.link": "Read more to get started using data files", "dashboard.dataView.dataView.update.message": "Updated your data entries", + "dashboard.dataView.dataView.createNew": "Create new data file", + "dashboard.dataView.dataView.selectDataFolder": "Select data folder", + "dashboard.dataView.dataView.closeSelectedDataFile": "Close data file", "dashboard.dataView.emptyView.heading": "Select your date type first", @@ -783,6 +786,9 @@ "listeners.panel.dataListener.aiSuggestTaxonomy.noData.error": "No article data", "listeners.panel.dataListener.getDataFileEntries.noDataFiles.error": "Couldn't find data file entries", "listeners.panel.dataListener.pushMetadata.frontMatter.error": "Something went wrong while parsing your front matter. Please check the contents of your file.", + "listeners.panel.dataListener.createDataFile.inputTitle": "What is the name of the data file?", + "listeners.panel.dataListener.createDataFile.error": "No data file id or path defined.", + "listeners.panel.dataListener.createDataFile.noFileName": "No filename provided.", "listeners.panel.taxonomyListener.aiSuggestTaxonomy.noEditor.error": "No active editor", diff --git a/package.json b/package.json index 109c4c38..b1467d61 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "color": "#0e131f", "theme": "dark" }, - "badges": [ - { + "badges": [{ "description": "version", "url": "https://img.shields.io/github/package-json/v/estruyf/vscode-front-matter?color=green&label=vscode-front-matter&style=flat-square", "href": "https://github.com/estruyf/vscode-front-matter" @@ -71,8 +70,7 @@ "**/.frontmatter/config/*.json": "jsonc" } }, - "keybindings": [ - { + "keybindings": [{ "command": "frontMatter.dashboard", "key": "alt+d" }, @@ -96,23 +94,19 @@ } ], "viewsContainers": { - "activitybar": [ - { - "id": "frontmatter-explorer", - "title": "FM", - "icon": "$(fm-logo)" - } - ] + "activitybar": [{ + "id": "frontmatter-explorer", + "title": "FM", + "icon": "$(fm-logo)" + }] }, "views": { - "frontmatter-explorer": [ - { - "id": "frontMatter.explorer", - "name": "Front Matter", - "icon": "$(fm-logo)", - "type": "webview" - } - ] + "frontmatter-explorer": [{ + "id": "frontMatter.explorer", + "name": "Front Matter", + "icon": "$(fm-logo)", + "type": "webview" + }] }, "configuration": { "title": "%settings.configuration.title%", @@ -180,8 +174,7 @@ "frontMatter.content.defaultFileType": { "type": "string", "default": "md", - "oneOf": [ - { + "oneOf": [{ "enum": [ "md", "mdx" @@ -197,8 +190,7 @@ "frontMatter.content.defaultSorting": { "type": "string", "default": "", - "oneOf": [ - { + "oneOf": [{ "enum": [ "LastModifiedAsc", "LastModifiedDesc", @@ -550,8 +542,7 @@ "categories" ], "markdownDescription": "%setting.frontMatter.content.filters.markdownDescription%", - "items": [ - { + "items": [{ "type": "string", "enum": [ "contentFolders", @@ -625,8 +616,7 @@ "command": { "$id": "#scriptCommand", "type": "string", - "anyOf": [ - { + "anyOf": [{ "enum": [ "node", "bash", @@ -822,8 +812,7 @@ "title", "file" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -870,6 +859,20 @@ "type": "boolean", "description": "%setting.frontMatter.data.folders.items.properties.singleEntry.description%", "default": false + }, + "enableFileCreation": { + "type": "boolean", + "description": "%setting.frontMatter.data.folders.items.properties.enableFileCreation.description%", + "default": false + }, + "fileType": { + "type": "string", + "default": "json", + "enum": [ + "json", + "yaml" + ], + "description": "%setting.frontMatter.data.folders.items.properties.fileType.description%" } }, "additionalProperties": false, @@ -877,8 +880,7 @@ "id", "path" ], - "anyOf": [ - { + "anyOf": [{ "required": [ "schema" ] @@ -1119,29 +1121,26 @@ } } }, - "default": [ - { - "name": "default", - "fileTypes": null, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Caption", - "name": "caption", - "type": "string" - }, - { - "title": "Alt text", - "name": "alt", - "type": "string" - } - ] - } - ], + "default": [{ + "name": "default", + "fileTypes": null, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Caption", + "name": "caption", + "type": "string" + }, + { + "title": "Alt text", + "name": "alt", + "type": "string" + } + ] + }], "scope": "Media" }, "frontMatter.media.supportedMimeTypes": { @@ -1244,8 +1243,7 @@ "fileType": { "type": "string", "default": "", - "oneOf": [ - { + "oneOf": [{ "enum": [ "md", "mdx" @@ -1384,8 +1382,7 @@ "default": "", "description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -1586,8 +1583,7 @@ "type", "name" ], - "allOf": [ - { + "allOf": [{ "if": { "properties": { "type": { @@ -1799,51 +1795,48 @@ "fields" ] }, - "default": [ - { - "name": "default", - "pageBundle": false, - "fields": [ - { - "title": "Title", - "name": "title", - "type": "string" - }, - { - "title": "Description", - "name": "description", - "type": "string" - }, - { - "title": "Publishing date", - "name": "date", - "type": "datetime", - "default": "{{now}}", - "isPublishDate": true - }, - { - "title": "Content preview", - "name": "preview", - "type": "image" - }, - { - "title": "Is in draft", - "name": "draft", - "type": "boolean" - }, - { - "title": "Tags", - "name": "tags", - "type": "tags" - }, - { - "title": "Categories", - "name": "categories", - "type": "categories" - } - ] - } - ], + "default": [{ + "name": "default", + "pageBundle": false, + "fields": [{ + "title": "Title", + "name": "title", + "type": "string" + }, + { + "title": "Description", + "name": "description", + "type": "string" + }, + { + "title": "Publishing date", + "name": "date", + "type": "datetime", + "default": "{{now}}", + "isPublishDate": true + }, + { + "title": "Content preview", + "name": "preview", + "type": "image" + }, + { + "title": "Is in draft", + "name": "draft", + "type": "boolean" + }, + { + "title": "Tags", + "name": "tags", + "type": "tags" + }, + { + "title": "Categories", + "name": "categories", + "type": "categories" + } + ] + }], "scope": "Taxonomy" }, "frontMatter.taxonomy.customTaxonomy": { @@ -1856,8 +1849,7 @@ "type": "string", "description": "%setting.frontMatter.taxonomy.customTaxonomy.items.properties.id.description%", "not": { - "anyOf": [ - { + "anyOf": [{ "const": "" }, { @@ -2052,8 +2044,7 @@ } } }, - "commands": [ - { + "commands": [{ "command": "frontMatter.project.switch", "title": "%command.frontMatter.project.switch%", "category": "Front Matter", @@ -2385,21 +2376,16 @@ } } ], - "submenus": [ - { - "id": "frontmatter.submenu", - "label": "Front Matter" - } - ], + "submenus": [{ + "id": "frontmatter.submenu", + "label": "Front Matter" + }], "menus": { - "webview/context": [ - { - "command": "workbench.action.webview.openDeveloperTools", - "when": "frontMatter:isDevelopment" - } - ], - "editor/title": [ - { + "webview/context": [{ + "command": "workbench.action.webview.openDeveloperTools", + "when": "frontMatter:isDevelopment" + }], + "editor/title": [{ "command": "frontMatter.markup.heading", "group": "navigation@-133", "when": "frontMatter:file:isValid == true && frontMatter:markdown:wysiwyg" @@ -2485,14 +2471,11 @@ "when": "resourceFilename == 'frontmatter.json'" } ], - "explorer/context": [ - { - "submenu": "frontmatter.submenu", - "group": "frontmatter@1" - } - ], - "frontmatter.submenu": [ - { + "explorer/context": [{ + "submenu": "frontmatter.submenu", + "group": "frontmatter@1" + }], + "frontmatter.submenu": [{ "command": "frontMatter.createFromTemplate", "when": "explorerResourceIsFolder", "group": "frontmatter@1" @@ -2508,8 +2491,7 @@ "group": "frontmatter@3" } ], - "commandPalette": [ - { + "commandPalette": [{ "command": "frontMatter.init", "when": "frontMatterCanInit" }, @@ -2686,8 +2668,7 @@ "when": "frontMatter:file:isValid == true" } ], - "view/title": [ - { + "view/title": [{ "command": "frontMatter.docs", "group": "navigation@-1", "when": "view == frontMatter.explorer" @@ -2724,16 +2705,13 @@ } ] }, - "languages": [ - { - "id": "frontmatter.project.output", - "mimetypes": [ - "text/x-code-output" - ] - } - ], - "grammars": [ - { + "languages": [{ + "id": "frontmatter.project.output", + "mimetypes": [ + "text/x-code-output" + ] + }], + "grammars": [{ "path": "./syntaxes/hugo.tmLanguage.json", "scopeName": "frontmatter.markdown.hugo", "injectTo": [ @@ -2746,48 +2724,45 @@ "path": "./syntaxes/frontmatter-output.tmLanguage.json" } ], - "walkthroughs": [ - { - "id": "frontmatter.welcome", - "title": "Get started with Front Matter", - "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", - "steps": [ - { - "id": "frontmatter.welcome.init", - "title": "Get started", - "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", - "media": { - "markdown": "assets/walkthrough/get-started.md" - }, - "completionEvents": [ - "onContext:frontMatterInitialized" - ] + "walkthroughs": [{ + "id": "frontmatter.welcome", + "title": "Get started with Front Matter", + "description": "Discover the features of Front Matter and learn how to use the CMS for your SSG or static site.", + "steps": [{ + "id": "frontmatter.welcome.init", + "title": "Get started", + "description": "Initial steps to get started.\n[Open dashboard](command:frontMatter.dashboard)", + "media": { + "markdown": "assets/walkthrough/get-started.md" }, - { - "id": "frontmatter.welcome.documentation", - "title": "Documentation", - "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", - "media": { - "markdown": "assets/walkthrough/documentation.md" - }, - "completionEvents": [ - "onLink:https://frontmatter.codes/docs" - ] + "completionEvents": [ + "onContext:frontMatterInitialized" + ] + }, + { + "id": "frontmatter.welcome.documentation", + "title": "Documentation", + "description": "Check out the documentation for Front Matter.\n[View our documentation](https://frontmatter.codes/docs)", + "media": { + "markdown": "assets/walkthrough/documentation.md" }, - { - "id": "frontmatter.welcome.supporter", - "title": "Support the project", - "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", - "media": { - "markdown": "assets/walkthrough/support-the-project.md" - }, - "completionEvents": [ - "onLink:https://github.com/sponsors/estruyf" - ] - } - ] - } - ] + "completionEvents": [ + "onLink:https://frontmatter.codes/docs" + ] + }, + { + "id": "frontmatter.welcome.supporter", + "title": "Support the project", + "description": "Become a supporter.\n[Support the project](https://github.com/sponsors/estruyf)", + "media": { + "markdown": "assets/walkthrough/support-the-project.md" + }, + "completionEvents": [ + "onLink:https://github.com/sponsors/estruyf" + ] + } + ] + }] }, "scripts": { "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", @@ -2915,4 +2890,4 @@ "dependencies": { "@radix-ui/react-dropdown-menu": "^2.0.6" } -} +} \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index d7f7f44b..7fc0537f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -145,6 +145,8 @@ "setting.frontMatter.data.folders.items.properties.path.description": "Path to the folder to load files.", "setting.frontMatter.data.folders.items.properties.type.description": "If you are using data types, you can specify your type ID.", "setting.frontMatter.data.folders.items.properties.singleEntry.description": "If you want to use a single entry for your data files in the folder.", + "setting.frontMatter.data.folders.items.properties.enableFileCreation.description": "Enable the creation of new data files in the folder.", + "setting.frontMatter.data.folders.items.properties.fileType.description": "Defines the file type for when the file creation is enabled. JSON is the default.", "setting.frontMatter.data.types.markdownDescription": "Specify the data types. These types can be used in for your data files. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.data.types) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.data.types%22%5D)", "setting.frontMatter.data.types.items.properties.id.description": "Your unique ID you want to use for your data type.", "setting.frontMatter.file.preserveCasing.markdownDescription": "Specify if you want to preserve the casing of your file names from the title. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.file.preservecasing) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.file.preservecasing%22%5D)", diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 5fa5f317..82c3dca6 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -557,6 +557,22 @@ export class Folders { return parseWinPath(absPath); } + /** + * Converts a given file path to a workspace-relative path. + * + * @param path - The file path to convert. + * @returns The workspace-relative path. + */ + public static wsPath(path: string) { + const wsFolder = Folders.getWorkspaceFolder(); + let absPath = parseWinPath(path).replace( + parseWinPath(wsFolder?.fsPath || ''), + WORKSPACE_PLACEHOLDER + ); + absPath = isWindows() ? absPath.split('\\').join('/') : absPath; + return absPath; + } + /** * Generate relative folder path * @param folder diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index 66f35ca2..0cdce18f 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -50,6 +50,7 @@ export enum DashboardMessage { // Data dashboard getDataEntries = 'getDataEntries', putDataEntries = 'putDataEntries', + createDataFile = 'createDataFile', // Snippets dashboard insertSnippet = 'insertSnippet', diff --git a/src/dashboardWebView/components/DataView/DataView.tsx b/src/dashboardWebView/components/DataView/DataView.tsx index d9dd185c..bfe2ea2d 100644 --- a/src/dashboardWebView/components/DataView/DataView.tsx +++ b/src/dashboardWebView/components/DataView/DataView.tsx @@ -5,7 +5,7 @@ import { SettingsSelector } from '../../state'; import { DataForm } from './DataForm'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { DataFile } from '../../../models/DataFile'; -import { Messenger } from '@estruyf/vscode/dist/client'; +import { messageHandler, Messenger } from '@estruyf/vscode/dist/client'; import { DashboardMessage } from '../../DashboardMessage'; import { SponsorMsg } from '../Layout/SponsorMsg'; import { EventData } from '@estruyf/vscode'; @@ -15,15 +15,18 @@ import { arrayMoveImmutable } from 'array-move'; import { EmptyView } from './EmptyView'; import { Container } from './SortableContainer'; import { SortableItem } from './SortableItem'; -import { ChevronRightIcon, CircleStackIcon } from '@heroicons/react/24/outline'; +import { ChevronRightIcon, CircleStackIcon, EyeIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { DataType } from '../../../models/DataType'; import { GeneralCommands, WEBSITE_LINKS } from '../../../constants'; import { NavigationItem } from '../Layout'; -import * as l10n from '@vscode/l10n'; -import { LocalizationKey } from '../../../localization'; +import { LocalizationKey, localize } from '../../../localization'; import { DropdownMenu, DropdownMenuContent } from '../../../components/shadcn/Dropdown'; import { MenuButton, MenuItem } from '../Menu'; import { Transition } from '@headlessui/react'; +import { DataFolder } from '../../../models'; +import { ActionsBarItem } from '../Header/ActionsBarItem'; +import { Spinner } from '../Common/Spinner'; +import { openFile } from '../../utils/MessageHandlers'; export interface IDataViewProps { } @@ -33,6 +36,7 @@ export const DataView: React.FunctionComponent = ( const [selectedData, setSelectedData] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); const [dataEntries, setDataEntries] = useState(null); + const [loading, setLoading] = useState(false); const settings = useRecoilValue(SettingsSelector); const setSchema = (dataFile: DataFile) => { @@ -71,6 +75,7 @@ export const DataView: React.FunctionComponent = ( return; } + debugger const dataClone: any[] = Object.assign([], dataEntries); if (selectedIndex !== null && selectedIndex !== undefined) { dataClone[selectedIndex] = data; @@ -110,11 +115,23 @@ export const DataView: React.FunctionComponent = ( entries: data }); - Messenger.send(DashboardMessage.showNotification, l10n.t(LocalizationKey.dashboardDataViewDataViewUpdateMessage)); + Messenger.send(DashboardMessage.showNotification, localize(LocalizationKey.dashboardDataViewDataViewUpdateMessage)); }, [selectedData] ); + const createDataFile = (folder: DataFolder) => { + setLoading(true); + messageHandler.request(DashboardMessage.createDataFile, folder).then(dataFile => { + if (dataFile) { + setSchema(dataFile); + } + setLoading(false); + }).catch((_: any) => { + setLoading(false); + }); + } + const dataEntry = useMemo(() => { if (selectedData?.singleEntry) { return dataEntries || {}; @@ -125,10 +142,40 @@ export const DataView: React.FunctionComponent = ( : null; }, [selectedData, , dataEntries, selectedIndex]); + // Retrieve the data files, check if they have a schema or ID, if not, they shouldn't be shown + const dataFiles = useMemo(() => { + return (settings?.dataFiles || []) + .map((dataFile: DataFile) => { + if (!dataFile.schema && !dataFile.id) { + return null; + } + + const clonedFile = Object.assign({}, dataFile); + + if (clonedFile.type) { + const dataType = settings?.dataTypes?.find( + (dataType: DataType) => dataType.id === clonedFile.type + ); + if (!dataType) { + return null; + } + clonedFile.schema = Object.assign({}, dataType.schema); + } + + return clonedFile; + }) + .filter((d) => d !== null) as DataFile[]; + }, [settings?.dataFiles]); + + const fileCreationFolders = useMemo(() => { + return (settings?.dataFolders || []) + .filter((folder) => folder.enableFileCreation); + }, [settings?.dataFolders]); + useEffect(() => { Messenger.listen(messageListener); - Messenger.send(DashboardMessage.setTitle, l10n.t(LocalizationKey.dashboardHeaderTabsData)); + Messenger.send(DashboardMessage.setTitle, localize(LocalizationKey.dashboardHeaderTabsData)); Messenger.send(GeneralCommands.toVSCode.logging.info, { message: 'Data view loaded', @@ -140,29 +187,6 @@ export const DataView: React.FunctionComponent = ( }; }, []); - // Retrieve the data files, check if they have a schema or ID, if not, they shouldn't be shown - const dataFiles = (settings?.dataFiles || []) - .map((dataFile: DataFile) => { - if (!dataFile.schema && !dataFile.id) { - return null; - } - - const clonedFile = Object.assign({}, dataFile); - - if (clonedFile.type) { - const dataType = settings?.dataTypes?.find( - (dataType: DataType) => dataType.id === clonedFile.type - ); - if (!dataType) { - return null; - } - clonedFile.schema = Object.assign({}, dataType.schema); - } - - return clonedFile; - }) - .filter((d) => d !== null) as DataFile[]; - return (
@@ -176,7 +200,7 @@ export const DataView: React.FunctionComponent = ( className={`flex flex-col flex-grow overflow-y-auto border-r py-6 px-4 overflow-auto border-[var(--frontmatter-border)]`} >

- {l10n.t(LocalizationKey.dashboardDataViewDataViewSelect)} + {localize(LocalizationKey.dashboardDataViewDataViewSelect)}

@@ -300,14 +372,14 @@ export const DataView: React.FunctionComponent = (
-

{l10n.t(LocalizationKey.dashboardDataViewDataViewNoDataFiles)}

+

{localize(LocalizationKey.dashboardDataViewDataViewNoDataFiles)}

- {l10n.t(LocalizationKey.dashboardDataViewDataViewGetStartedLink)} + {localize(LocalizationKey.dashboardDataViewDataViewGetStartedLink)}

@@ -315,6 +387,8 @@ export const DataView: React.FunctionComponent = ( ) } + {loading && } + void; +} export const EmptyView: React.FunctionComponent = ( - props: React.PropsWithChildren + { folders, onCreate }: React.PropsWithChildren ) => { return ( @@ -15,6 +21,32 @@ export const EmptyView: React.FunctionComponent = (

{l10n.t(LocalizationKey.dashboardDataViewEmptyViewHeading)}

+ + { + onCreate && folders && folders.length > 0 && ( +
+ + + + + {folders.map((folder) => ( + onCreate(folder)} + /> + ))} + + +
+ ) + }
); }; diff --git a/src/dashboardWebView/components/Menu/MenuButton.tsx b/src/dashboardWebView/components/Menu/MenuButton.tsx index 6868e438..d58e52bc 100644 --- a/src/dashboardWebView/components/Menu/MenuButton.tsx +++ b/src/dashboardWebView/components/Menu/MenuButton.tsx @@ -1,26 +1,33 @@ import { ChevronDownIcon } from '@heroicons/react/24/solid'; import * as React from 'react'; import { DropdownMenuTrigger } from '../../../components/shadcn/Dropdown'; +import { cn } from '../../../utils/cn'; export interface IMenuButtonProps { label: string | JSX.Element; title: string; disabled?: boolean; + className?: string; + labelClass?: string; + buttonClass?: string; } export const MenuButton: React.FunctionComponent = ({ label, title, - disabled + disabled, + className, + labelClass, + buttonClass, }: React.PropsWithChildren) => { return ( -
-
+
+
{label}:
{title}