diff --git a/Composer/packages/client/src/constants/index.js b/Composer/packages/client/src/constants/index.js index 449e6aa9f1..e7f2869ce7 100644 --- a/Composer/packages/client/src/constants/index.js +++ b/Composer/packages/client/src/constants/index.js @@ -10,6 +10,8 @@ export const ActionTypes = { UPDATE_DIALOG: 'UPDATE_DIALOG', UPDATE_DIALOG_FAILURE: 'UPDATE_DIALOG_FAILURE', CREATE_DIALOG_SUCCESS: 'CREATE_DIALOG_SUCCESS', + UPDATE_LG_TEMPLATE: 'UPDATE_LG_TEMPLATE', + UPDATE_LG_FAILURE: 'UPDATE_LG_FAILURE', BOT_STATUS_SET: 'BOT_STATUS_SET', BOT_STATUS_SET_FAILURE: 'BOT_STATUS_SET_FAILURE', EDITOR_ADD: 'EDITOR_ADD', diff --git a/Composer/packages/client/src/pages/content/index.js b/Composer/packages/client/src/pages/content/index.js index 9e9eded8a2..5b481f7519 100644 --- a/Composer/packages/client/src/pages/content/index.js +++ b/Composer/packages/client/src/pages/content/index.js @@ -1,19 +1,36 @@ -import React, { Fragment } from 'react'; +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { Fragment } from 'react'; +import formatMessage from 'format-message'; +import Routes from './router'; import { Tree } from './../../components/Tree/index'; import { Conversation } from './../../components/Conversation/index'; +import { NavLink } from './../../components/NavLink/index'; +import { title, label, navLinkClass } from './styles'; +// todo: should wrap the NavLink to another component. export const ContentPage = () => { return (
-
Content
+
+
{formatMessage('Content')}
+ +
{formatMessage('Language Understanding')}
+
+ +
{formatMessage('Language Generation')}
+
+
- + + +
diff --git a/Composer/packages/client/src/pages/content/lg-settings/index.js b/Composer/packages/client/src/pages/content/lg-settings/index.js new file mode 100644 index 0000000000..7320031ac1 --- /dev/null +++ b/Composer/packages/client/src/pages/content/lg-settings/index.js @@ -0,0 +1,172 @@ +/* eslint-disable react/display-name */ +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import debounce from 'lodash.debounce'; +import { Fragment, useContext, useRef } from 'react'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; +import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; +import { DetailsList, DetailsListLayoutMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import formatMessage from 'format-message'; + +import { Store } from '../../../store/index'; + +import { scrollablePaneRoot, title, label } from './styles'; + +export function LanguageGenerationSettings() { + const { state, actions } = useContext(Store); + const { lgTemplates } = state; + const updateLG = useRef(debounce(actions.updateLgTemplate, 500)).current; + const tableColums = [ + { + key: 'name', + name: 'Name', + fieldName: 'name', + minWidth: 150, + maxWidth: 200, + isRowHeader: true, + isResizable: true, + data: 'string', + onRender: (item, index) => { + return ( + + updateTemplateContent(index, newName, item.content)} + /> + + ); + }, + }, + { + key: 'type', + name: 'Type', + fieldName: 'type', + minWidth: 50, + maxWidth: 100, + data: 'string', + isPadded: true, + onRender: item => { + return {item.type}; + }, + }, + { + key: 'phrase', + name: 'Sample phrase', + fieldName: 'samplePhrase', + minWidth: 500, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item, index) => { + return {getTemplatePhrase(item, index)}; + }, + }, + ]; + + function updateTemplateContent(index, templateName, content) { + const newTemplate = lgTemplates[index]; + newTemplate.name = templateName; + newTemplate.content = content; + + const payload = { + name: templateName, + content: newTemplate, + }; + + updateLG(payload); + } + + function getTemplatePhrase(item, index) { + return ( + updateTemplateContent(index, item.name, newValue)} + /> + ); + } + + function onRenderDetailsHeader(props, defaultRender) { + return ( + + {defaultRender({ + ...props, + // eslint-disable-next-line react/display-name + onRenderColumnHeaderTooltip: tooltipHostProps => , + })} + + ); + } + + const items = []; + if (lgTemplates) { + lgTemplates.forEach(template => { + items.push({ + name: template.name, + value: template.name, + absolutePath: template.absolutePath, + type: template.type, + content: template.content, + comments: template.comments, + }); + }); + } + + const groups = []; + let currentKey = ''; + let itemCount = 0; + lgTemplates.forEach((template, index) => { + if (template.absolutePath !== currentKey) { + if (itemCount !== 0) { + const pathItems = currentKey.split(/[\\/]+/g); + groups.push({ + name: pathItems[pathItems.length - 1], + count: itemCount, + key: currentKey, + startIndex: index - itemCount, + }); + itemCount = 0; + } + currentKey = template.absolutePath; + } + itemCount++; + if (index === lgTemplates.length - 1) { + const pathItems = currentKey.split(/[\\/]+/g); + groups.push({ + name: pathItems[pathItems.length - 1], + count: itemCount, + key: currentKey, + startIndex: index - itemCount + 1, + }); + } + }); + + return ( + +
+
{formatMessage('Content > Language Generation')}
+
{formatMessage('Templates')}
+ + + +
+
+ ); +} diff --git a/Composer/packages/client/src/pages/content/lg-settings/styles.js b/Composer/packages/client/src/pages/content/lg-settings/styles.js new file mode 100644 index 0000000000..772c0caa94 --- /dev/null +++ b/Composer/packages/client/src/pages/content/lg-settings/styles.js @@ -0,0 +1,23 @@ +import { css } from '@emotion/core'; + +export const scrollablePaneRoot = css` + margin-top: 80px; + margin-left: 15px; +`; + +export const title = css` + font-weight: bold; + color: #5f5f5f; + font-size: 20px; + line-height: 40px; + padding-left: 15px; +`; + +export const label = css` + text-decoration: none; + color: rgb(95, 95, 95); + font-size: 13px; + font-weight: bold; + line-height: 30px; + padding-left: 15px; +`; diff --git a/Composer/packages/client/src/pages/content/lu-settings/index.js b/Composer/packages/client/src/pages/content/lu-settings/index.js new file mode 100644 index 0000000000..cb8dd4b4f5 --- /dev/null +++ b/Composer/packages/client/src/pages/content/lu-settings/index.js @@ -0,0 +1,7 @@ +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import formatMessage from 'format-message'; + +export function LanguageUnderstandingSettings() { + return
{formatMessage('Language Understanding')}
; +} diff --git a/Composer/packages/client/src/pages/content/router.js b/Composer/packages/client/src/pages/content/router.js new file mode 100644 index 0000000000..612875f2d5 --- /dev/null +++ b/Composer/packages/client/src/pages/content/router.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { Router, Redirect } from '@reach/router'; + +import { LanguageUnderstandingSettings } from './lu-settings'; +import { LanguageGenerationSettings } from './lg-settings'; + +const Routes = () => ( + + + + + +); + +export default Routes; diff --git a/Composer/packages/client/src/pages/content/styles.js b/Composer/packages/client/src/pages/content/styles.js new file mode 100644 index 0000000000..ef3f143be3 --- /dev/null +++ b/Composer/packages/client/src/pages/content/styles.js @@ -0,0 +1,28 @@ +import { css } from '@emotion/core'; + +export const label = css` + font-size: 13px; +`; + +export const title = css` + font-weight: bold; + color: #5f5f5f; + font-size: 20px; + line-height: 40px; + padding-left: 15px; +`; + +export const navLinkClass = { + default: { + display: 'block', + textDecoration: 'none', + color: '#5f5f5f', + fontSize: '13px', + fontWeight: 'bold', + lineHeight: '30px', + paddingLeft: '15px', + }, + activestyle: { + color: '#0083cb', + }, +}; diff --git a/Composer/packages/client/src/router.js b/Composer/packages/client/src/router.js index d6494d8277..93094bb1a7 100644 --- a/Composer/packages/client/src/router.js +++ b/Composer/packages/client/src/router.js @@ -9,8 +9,8 @@ import { ContentPage } from './pages/content/index'; const Routes = () => ( - + ); diff --git a/Composer/packages/client/src/store/action/project.js b/Composer/packages/client/src/store/action/project.js index 88c7f7f89f..d6be60e018 100644 --- a/Composer/packages/client/src/store/action/project.js +++ b/Composer/packages/client/src/store/action/project.js @@ -118,3 +118,19 @@ export async function createDialog(dispatch, { name, steps }) { console.error(err); } } + +export async function updateLgTemplate(dispatch, { name, content }) { + try { + const response = await axios.put(`${BASEURL}/projects/opened/lgTemplates/${name}`, { name, content }); + dispatch({ + type: ActionTypes.UPDATE_LG_TEMPLATE, + payload: { response }, + }); + } catch (err) { + dispatch({ + type: ActionTypes.UPDATE_LG_FAILURE, + payload: null, + error: err, + }); + } +} diff --git a/Composer/packages/client/src/store/index.js b/Composer/packages/client/src/store/index.js index 3c58faa6a7..2fa52a7642 100644 --- a/Composer/packages/client/src/store/index.js +++ b/Composer/packages/client/src/store/index.js @@ -17,6 +17,7 @@ const initialState = { focusedStorageFolder: {}, botStatus: 'stopped', storageExplorerStatus: '', + lgTemplates: [], }; export function StoreProvider(props) { diff --git a/Composer/packages/client/src/store/reducer/index.js b/Composer/packages/client/src/store/reducer/index.js index 3f448a75da..1b28fa1fb9 100644 --- a/Composer/packages/client/src/store/reducer/index.js +++ b/Composer/packages/client/src/store/reducer/index.js @@ -12,6 +12,7 @@ const closeCurrentProject = state => { const getProjectSuccess = (state, { response }) => { state.dialogs = response.data.dialogs; state.botProjFile = response.data.botFile; + state.lgTemplates = response.data.lgTemplates; return state; }; @@ -25,6 +26,11 @@ const createDialogSuccess = (state, { response }) => { return state; }; +const updateLgTemplate = (state, { response }) => { + state.lgTemplates = response.data.lgTemplates; + return state; +}; + const updateProjFile = (state, { response }) => { state.botProjFile = response.data.botFile; return state; @@ -116,4 +122,5 @@ export const reducer = createReducer({ [ActionTypes.NAVIGATE_DOWN]: navigateDown, [ActionTypes.FOCUS_TO]: focusTo, [ActionTypes.CLEAR_NAV_HISTORY]: clearNavHistory, + [ActionTypes.UPDATE_LG_TEMPLATE]: updateLgTemplate, }); diff --git a/Composer/packages/server/__tests__/models/bot/botProject.test.ts b/Composer/packages/server/__tests__/models/bot/botProject.test.ts index 288339d261..1e84cae4b4 100644 --- a/Composer/packages/server/__tests__/models/bot/botProject.test.ts +++ b/Composer/packages/server/__tests__/models/bot/botProject.test.ts @@ -116,3 +116,34 @@ describe('copyTo', () => { expect(project.dialogs.length).toBe(3); }); }); + +describe('update lg template', () => { + it('should update the lg template.', async () => { + const initFiles = [ + { + name: 'test.lg', + content: '# greet\n- Hello!', + path: path.join(__dirname, '../../mocks/test.lg'), + relativePath: path.relative(proj.dir, path.join(__dirname, '../../mocks/test.lg')), + }, + ]; + const initValue = { + id: 1, + name: 'greet', + content: '- Hello!', + absolutePath: path.join(__dirname, '../../mocks/test.lg'), + }; + const newValue = { + id: 1, + name: 'updated', + content: '- new value', + absolutePath: path.join(__dirname, '../../mocks/test.lg'), + }; + await proj.lgIndexer.index(initFiles); + const lgTemplates = await proj.updateLgTemplate('test', newValue); + const aTemplate = lgTemplates.find(f => f.name.startsWith('updated')); + // @ts-ignore + expect(aTemplate).toEqual(newValue); + await proj.updateLgTemplate('test', initValue); + }); +}); diff --git a/Composer/packages/server/__tests__/models/bot/indexers/lgIndexer.test.ts b/Composer/packages/server/__tests__/models/bot/indexers/lgIndexer.test.ts new file mode 100644 index 0000000000..2e15932ba3 --- /dev/null +++ b/Composer/packages/server/__tests__/models/bot/indexers/lgIndexer.test.ts @@ -0,0 +1,49 @@ +import path from 'path'; + +import { BotProject } from '../../../../src/models/bot/botProject'; +import { BotProjectRef } from '../../../../src/models/bot/interface'; + +jest.mock('azure-storage', () => { + return {}; +}); + +const mockProjectRef: BotProjectRef = { + storageId: 'default', + path: path.join(__dirname, '../../mocks/1.botproj'), +}; + +const proj = new BotProject(mockProjectRef); + +describe('Index lg files', () => { + it('should index the lg file.', async () => { + const initFiles = [ + { + name: 'test.lg', + content: '# greet\n- Hello!', + path: path.join(__dirname, '../../mocks/test.lg'), + relativePath: path.relative(proj.dir, path.join(__dirname, '../../mocks/test.lg')), + }, + { + name: 'a.dialog', + content: { old: 'value' }, + path: path.join(__dirname, '../../mocks/a.dialog'), + relativePath: path.relative(proj.dir, path.join(__dirname, '../../mocks/a.dialog')), + }, + ]; + const aTemplate = { + id: 1, + name: 'greet', + content: '- Hello!', + absolutePath: path.join(__dirname, '../../mocks/test.lg'), + parameters: [], + type: 'Rotate', + comments: '', + }; + await proj.lgIndexer.index(initFiles); + + const lgTemplates = await proj.lgIndexer.getLgTemplates(); + // @ts-ignore + expect(lgTemplates.length).toEqual(1); + expect(aTemplate).toEqual(lgTemplates[0]); + }); +}); diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index 9b7f2f53a3..8f95291da1 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -5,7 +5,7 @@ import { BotProjectRef } from '../models/bot/interface'; async function getProject(req: Request, res: Response) { if (ProjectService.currentBotProject !== undefined) { - ProjectService.currentBotProject.index(); + await ProjectService.currentBotProject.index(); const project = await ProjectService.currentBotProject.getIndexes(); res.status(200).json({ ...project }); } else { @@ -98,11 +98,21 @@ async function createDialogFromTemplate(req: Request, res: Response) { } } +async function updateLgTemplate(req: Request, res: Response) { + if (ProjectService.currentBotProject !== undefined) { + const lgTemplates = await ProjectService.currentBotProject.updateLgTemplate(req.body.name, req.body.content); + res.status(200).json({ lgTemplates }); + } else { + res.status(404).json({ error: 'No bot project opened' }); + } +} + export const ProjectController = { getProject: getProject, openProject: openProject, updateDialog: updateDialog, createDialogFromTemplate: createDialogFromTemplate, + updateLgTemplate: updateLgTemplate, updateBotFile: updateBotFile, saveProjectAs: saveProjectAs, }; diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index f37917cff6..07ce5403cf 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -9,6 +9,7 @@ import DIALOG_TEMPLATE from './../../store/dialogTemplate.json'; import { IFileStorage } from './../storage/interface'; import { BotProjectRef, FileInfo, BotProjectFileContent } from './interface'; import { DialogIndexer } from './indexers/dialogIndexers'; +import { LGIndexer } from './indexers/lgIndexer'; // TODO: // 1. refactor this class to use on IFileStorage instead of operating on fs @@ -22,6 +23,7 @@ export class BotProject { public files: FileInfo[] = []; public fileStorage: IFileStorage; public dialogIndexer: DialogIndexer; + public lgIndexer: LGIndexer; constructor(ref: BotProjectRef) { this.ref = ref; @@ -31,16 +33,19 @@ export class BotProject { this.fileStorage = StorageService.getStorageClient(this.ref.storageId); this.dialogIndexer = new DialogIndexer(this.fileStorage); + this.lgIndexer = new LGIndexer(this.fileStorage); } public index = async () => { this.files = await this._getFiles(); this.dialogIndexer.index(this.files); + this.lgIndexer.index(this.files); }; public getIndexes = () => { return { dialogs: this.dialogIndexer.getDialogs(), + lgTemplates: this.lgIndexer.getLgTemplates(), botFile: this.getBotFile(), }; }; @@ -77,6 +82,12 @@ export class BotProject { return this.dialogIndexer.getDialogs(); }; + public updateLgTemplate = async (name: string, content: any) => { + const newFileContent = await this.lgIndexer.updateLgTemplate(content); + this._updateFile(`${name.trim()}.lg`, newFileContent); + return this.lgIndexer.getLgTemplates(); + }; + public copyFiles = async (prevFiles: FileInfo[]) => { if (!(await this.fileStorage.exists(this.dir))) { await this.fileStorage.mkDir(this.dir); diff --git a/Composer/packages/server/src/models/bot/indexers/index.ts b/Composer/packages/server/src/models/bot/indexers/index.ts index f0c18d9a56..093549af56 100644 --- a/Composer/packages/server/src/models/bot/indexers/index.ts +++ b/Composer/packages/server/src/models/bot/indexers/index.ts @@ -1 +1,2 @@ export * from './dialogIndexers'; +export * from './lgIndexer'; diff --git a/Composer/packages/server/src/models/bot/indexers/lgIndexer.ts b/Composer/packages/server/src/models/bot/indexers/lgIndexer.ts new file mode 100644 index 0000000000..a08bd1c88d --- /dev/null +++ b/Composer/packages/server/src/models/bot/indexers/lgIndexer.ts @@ -0,0 +1,129 @@ +import path from 'path'; + +import { IFileStorage } from 'src/models/storage/interface'; + +import { FileInfo, LGTemplate } from '../interface'; + +export class LGIndexer { + private lgTemplates: LGTemplate[] = []; + private storage: IFileStorage; + + constructor(storage: IFileStorage) { + this.storage = storage; + } + + private getNewTemplate( + id: number, + absolutePath: string, + name: string = '', + content: string = '', + comments: string = '', + parameters: string[] = [] + ) { + return { + id: id, + name: name, + absolutePath: absolutePath, + type: 'Rotate', + content: content, + comments: comments, + parameters: parameters, + }; + } + + public index(files: FileInfo[]) { + if (files.length === 0) return []; + + const lgTemplates: LGTemplate[] = []; + let count = 1; + + for (const file of files) { + const extName = path.extname(file.name); + const absolutePath = file.path; + // todo: use lg parser. + if (extName === '.lg') { + const lines = file.content.split(/\r?\n/) || []; + let newTemplate: LGTemplate = this.getNewTemplate(0, ''); + lines.forEach((line: string, index: number) => { + if (!line.trim() || line.trim().startsWith('>')) { + if (newTemplate.name) { + lgTemplates.push(newTemplate); + newTemplate = this.getNewTemplate(count++, absolutePath, '', line); + } else if (index === lines.length - 1 && newTemplate.comments) { + newTemplate.id = count++; + newTemplate.absolutePath = absolutePath; + newTemplate.comments += line; + newTemplate.content = newTemplate.content.trim(); + lgTemplates.push(newTemplate); + } else { + newTemplate.comments += line + '\n'; + } + return; + } + + if (line.trim().startsWith('#')) { + if (newTemplate.name) { + newTemplate.content = newTemplate.content.trim(); + lgTemplates.push(newTemplate); + newTemplate = this.getNewTemplate(count++, absolutePath); + } + newTemplate.id = count; + newTemplate.name = line.trim().split(' ')[1]; + newTemplate.absolutePath = absolutePath; + return; + } + + if (line.trim().startsWith('- DEFAULT') || line.trim().startsWith('- IF')) { + newTemplate.type = 'Condition'; + } + newTemplate.content += line + '\n'; + + if (newTemplate.name && index === lines.length - 1) { + newTemplate.id = count++; + newTemplate.content = newTemplate.content.trim(); + lgTemplates.push(newTemplate); + } + }); + } + } + this.lgTemplates = lgTemplates; + } + + public getLgTemplates() { + return this.lgTemplates; + } + + public async updateLgTemplate(lgTemplate: LGTemplate) { + const absolutePath = lgTemplate.absolutePath; + const updatedIndex = this.lgTemplates.findIndex(template => lgTemplate.id === template.id); + if (updatedIndex >= 0) { + if (lgTemplate.name) { + this.lgTemplates[updatedIndex] = lgTemplate; + } else { + this.lgTemplates.splice(updatedIndex, 1); + } + let updatedLG = ''; + this.lgTemplates + .filter(template => template.absolutePath === lgTemplate.absolutePath) + .forEach(template => { + if (template.comments) { + updatedLG += `${template.comments}`; + } + if (template.name) { + if (template.content.indexOf('- IF') !== -1 || template.content.indexOf('- DEFAULT') !== -1) { + template.type = 'Condition'; + } else { + template.type = 'Rotate'; + } + updatedLG += `# ${template.name}` + '\n'; + updatedLG += `${template.content}` + '\n'; + } + }); + const newFileContent = updatedLG.trim() + '\n'; + await this.storage.writeFile(absolutePath, newFileContent); + return newFileContent; + } else { + throw new Error(`Lg template not found, id: ${lgTemplate.id}`); + } + } +} diff --git a/Composer/packages/server/src/models/bot/interface.ts b/Composer/packages/server/src/models/bot/interface.ts index c2c17e5d51..64a13b9fb5 100644 --- a/Composer/packages/server/src/models/bot/interface.ts +++ b/Composer/packages/server/src/models/bot/interface.ts @@ -22,3 +22,14 @@ export interface Dialog { content: any; path: string; } + +export interface LGTemplate { + id: number; + name: string; + type: string; + // for now parameters is not been used because it shown up with the name. + parameters: string[]; + content: any; + absolutePath: string; + comments: string; +} diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 3a3e370fb3..bf7163ca07 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -11,6 +11,7 @@ router.get('/projects/opened', ProjectController.getProject); router.put('/projects/opened', ProjectController.openProject); router.put('/projects/opened/dialogs/:dialogId', ProjectController.updateDialog); router.post('/projects/opened/dialogs', ProjectController.createDialogFromTemplate); +router.put('/projects/opened/lgTemplates/:lgId', ProjectController.updateLgTemplate); router.put('/projects/opened/botFile', ProjectController.updateBotFile); router.post('/projects/opened/project/saveAs', ProjectController.saveProjectAs); diff --git a/SampleBots/Planning - ToDoBot/common.lg b/SampleBots/Planning - ToDoBot/common.lg new file mode 100644 index 0000000000..2c12700bbb --- /dev/null +++ b/SampleBots/Planning - ToDoBot/common.lg @@ -0,0 +1,13 @@ +# Hello +- [Welcome(time)] {name} + +# Welcome(time) +- IF: {time == 'morning'} + - Good morning +- ELSEIF: {time == 'evening'} + - Good evening +- ELSE: + - How are you doing, + +# Exit +- Thanks for using todo bot. \ No newline at end of file diff --git a/SampleBots/Planning - ToDoBot/todo.lg b/SampleBots/Planning - ToDoBot/todo.lg index 89c4688414..b1a053f07f 100644 --- a/SampleBots/Planning - ToDoBot/todo.lg +++ b/SampleBots/Planning - ToDoBot/todo.lg @@ -1,11 +1,11 @@ -# ShowTodo -- CASE: {count(user.todos) > 0} +# ShowTodo +- IF: {count(user.todos) > 0} - ``` Your most recent @{count(user.todos)} tasks are @{humanize(user.todos, '[showSingleTodo]', '\n')} ``` -- DEFAULT: +- ELSE: - You don't have any tasks. # showSingleTodo(x) -- * {x} \ No newline at end of file +- * {x}