From d47b13647e774b1751d967c75f046b8f646a8a53 Mon Sep 17 00:00:00 2001 From: konrad Date: Wed, 29 Sep 2021 18:30:55 +0000 Subject: [PATCH] feat(natural language): make natural language prefixes configurable (#795) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/795 Co-authored-by: konrad Co-committed-by: konrad --- src/components/tasks/mixins/createTask.js | 3 +- .../tasks/partials/quick-add-magic.vue | 114 ++++++++++-------- src/helpers/quickAddMagicMode.ts | 14 +++ src/i18n/lang/en.json | 6 + src/modules/parseTaskText.test.js | 57 ++++++--- src/modules/parseTaskText.ts | 67 +++++++--- src/views/user/Settings.vue | 20 ++- 7 files changed, 192 insertions(+), 89 deletions(-) create mode 100644 src/helpers/quickAddMagicMode.ts diff --git a/src/components/tasks/mixins/createTask.js b/src/components/tasks/mixins/createTask.js index dd0aab01..283a64d7 100644 --- a/src/components/tasks/mixins/createTask.js +++ b/src/components/tasks/mixins/createTask.js @@ -7,6 +7,7 @@ import LabelTaskService from '@/services/labelTask' import {mapState} from 'vuex' import UserService from '@/services/user' import TaskService from '@/services/task' +import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode' export default { data() { @@ -26,7 +27,7 @@ export default { }), methods: { createNewTask(newTaskTitle, bucketId = 0, lId = 0, position = 0) { - const parsedTask = parseTaskText(newTaskTitle) + const parsedTask = parseTaskText(newTaskTitle, getQuickAddMagicMode()) const assignees = [] // Uses the following ways to get the list id of the new task: diff --git a/src/components/tasks/partials/quick-add-magic.vue b/src/components/tasks/partials/quick-add-magic.vue index e6d6061a..3bf46303 100644 --- a/src/components/tasks/partials/quick-add-magic.vue +++ b/src/components/tasks/partials/quick-add-magic.vue @@ -1,5 +1,5 @@ diff --git a/src/helpers/quickAddMagicMode.ts b/src/helpers/quickAddMagicMode.ts new file mode 100644 index 00000000..41769ebe --- /dev/null +++ b/src/helpers/quickAddMagicMode.ts @@ -0,0 +1,14 @@ +import {PrefixMode} from '@/modules/parseTaskText' + +const key = 'quickAddMagicMode' + +export const setQuickAddMagicMode = (mode: PrefixMode) => { + localStorage.setItem(key, mode) +} + +export const getQuickAddMagicMode = (): PrefixMode => { + const mode = localStorage.getItem(key) + + // @ts-ignore + return PrefixMode[mode] || PrefixMode.Disabled +} diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index e29e59b2..aa1801b3 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -96,6 +96,12 @@ "uploadAvatar": "Upload Avatar", "statusUpdateSuccess": "Avatar status was updated successfully!", "setSuccess": "The avatar has been set successfully!" + }, + "quickAddMagic": { + "title": "Quick Add Magic Mode", + "disabled": "Disabled", + "todoist": "Todoist", + "vikunja": "Vikunja" } }, "deletion": { diff --git a/src/modules/parseTaskText.test.js b/src/modules/parseTaskText.test.js index 6b445426..a4c9eba3 100644 --- a/src/modules/parseTaskText.test.js +++ b/src/modules/parseTaskText.test.js @@ -7,6 +7,29 @@ describe('Parse Task Text', () => { it('should return text with no intents as is', () => { expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum') }) + + it('should not parse text when disabled', () => { + const text = 'Lorem Ipsum today *label +list !2 @user' + const result = parseTaskText(text, 'disabled') + + expect(result.text).toBe(text) + }) + + it('should parse text in todoist mode when configured', () => { + const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', 'todoist') + + expect(result.text).toBe('Lorem Ipsum') + const now = new Date() + expect(result.date.getFullYear()).toBe(now.getFullYear()) + expect(result.date.getMonth()).toBe(now.getMonth()) + expect(result.date.getDate()).toBe(now.getDate()) + expect(result.labels).toHaveLength(1) + expect(result.labels[0]).toBe('label') + expect(result.list).toBe('list') + expect(result.priority).toBe(2) + expect(result.assignees).toHaveLength(1) + expect(result.assignees[0]).toBe('user') + }) describe('Date Parsing', () => { it('should not return any date if none was provided', () => { @@ -47,8 +70,8 @@ describe('Parse Task Text', () => { } for (const c in cases) { - it('should recognize today with a time ' + c, () => { - const result = parseTaskText('Lorem Ipsum today ' + c) + it(`should recognize today with a time ${c}`, () => { + const result = parseTaskText(`Lorem Ipsum today ${c}`) expect(result.text).toBe('Lorem Ipsum') const now = new Date() @@ -354,7 +377,7 @@ describe('Parse Task Text', () => { describe('Labels', () => { it('should parse labels', () => { - const result = parseTaskText('Lorem Ipsum @label1 @label2') + const result = parseTaskText('Lorem Ipsum *label1 *label2') expect(result.text).toBe('Lorem Ipsum') expect(result.labels).toHaveLength(2) @@ -362,7 +385,7 @@ describe('Parse Task Text', () => { expect(result.labels[1]).toBe('label2') }) it('should parse labels from the start', () => { - const result = parseTaskText('@label1 Lorem Ipsum @label2') + const result = parseTaskText('*label1 Lorem Ipsum *label2') expect(result.text).toBe('Lorem Ipsum') expect(result.labels).toHaveLength(2) @@ -370,7 +393,7 @@ describe('Parse Task Text', () => { expect(result.labels[1]).toBe('label2') }) it('should resolve duplicate labels', () => { - const result = parseTaskText('Lorem Ipsum @label1 @label1 @label2') + const result = parseTaskText('Lorem Ipsum *label1 *label1 *label2') expect(result.text).toBe('Lorem Ipsum') expect(result.labels).toHaveLength(2) @@ -378,14 +401,14 @@ describe('Parse Task Text', () => { expect(result.labels[1]).toBe('label2') }) it('should correctly parse labels with spaces in them', () => { - const result = parseTaskText(`Lorem @'label with space' Ipsum`) + const result = parseTaskText(`Lorem *'label with space' Ipsum`) expect(result.text).toBe('Lorem Ipsum') expect(result.labels).toHaveLength(1) expect(result.labels[0]).toBe('label with space') }) it('should correctly parse labels with spaces in them and "', () => { - const result = parseTaskText('Lorem @"label with space" Ipsum') + const result = parseTaskText('Lorem *"label with space" Ipsum') expect(result.text).toBe('Lorem Ipsum') expect(result.labels).toHaveLength(1) @@ -395,27 +418,27 @@ describe('Parse Task Text', () => { describe('List', () => { it('should parse a list', () => { - const result = parseTaskText('Lorem Ipsum #list') + const result = parseTaskText('Lorem Ipsum +list') expect(result.text).toBe('Lorem Ipsum') expect(result.list).toBe('list') }) it('should parse a list with a space in it', () => { - const result = parseTaskText(`Lorem Ipsum #'list with long name'`) + const result = parseTaskText(`Lorem Ipsum +'list with long name'`) expect(result.text).toBe('Lorem Ipsum') expect(result.list).toBe('list with long name') }) it('should parse a list with a space in it and "', () => { - const result = parseTaskText(`Lorem Ipsum #"list with long name"`) + const result = parseTaskText(`Lorem Ipsum +"list with long name"`) expect(result.text).toBe('Lorem Ipsum') expect(result.list).toBe('list with long name') }) it('should parse only the first list', () => { - const result = parseTaskText(`Lorem Ipsum #list1 #list2 #list3`) + const result = parseTaskText(`Lorem Ipsum +list1 +list2 +list3`) - expect(result.text).toBe('Lorem Ipsum #list2 #list3') + expect(result.text).toBe('Lorem Ipsum +list2 +list3') expect(result.list).toBe('list1') }) }) @@ -445,14 +468,14 @@ describe('Parse Task Text', () => { describe('Assignee', () => { it('should parse an assignee', () => { - const result = parseTaskText('Lorem Ipsum +user') + const result = parseTaskText('Lorem Ipsum @user') expect(result.text).toBe('Lorem Ipsum') expect(result.assignees).toHaveLength(1) expect(result.assignees[0]).toBe('user') }) it('should parse multiple assignees', () => { - const result = parseTaskText('Lorem Ipsum +user1 +user2 +user3') + const result = parseTaskText('Lorem Ipsum @user1 @user2 @user3') expect(result.text).toBe('Lorem Ipsum') expect(result.assignees).toHaveLength(3) @@ -461,7 +484,7 @@ describe('Parse Task Text', () => { expect(result.assignees[2]).toBe('user3') }) it('should parse avoid duplicate assignees', () => { - const result = parseTaskText('Lorem Ipsum +user1 +user1 +user2') + const result = parseTaskText('Lorem Ipsum @user1 @user1 @user2') expect(result.text).toBe('Lorem Ipsum') expect(result.assignees).toHaveLength(2) @@ -469,14 +492,14 @@ describe('Parse Task Text', () => { expect(result.assignees[1]).toBe('user2') }) it('should parse an assignee with a space in it', () => { - const result = parseTaskText(`Lorem Ipsum +'user with long name'`) + const result = parseTaskText(`Lorem Ipsum @'user with long name'`) expect(result.text).toBe('Lorem Ipsum') expect(result.assignees).toHaveLength(1) expect(result.assignees[0]).toBe('user with long name') }) it('should parse an assignee with a space in it and "', () => { - const result = parseTaskText(`Lorem Ipsum +"user with long name"`) + const result = parseTaskText(`Lorem Ipsum @"user with long name"`) expect(result.text).toBe('Lorem Ipsum') expect(result.assignees).toHaveLength(1) diff --git a/src/modules/parseTaskText.ts b/src/modules/parseTaskText.ts index 0df53554..d3fbf84b 100644 --- a/src/modules/parseTaskText.ts +++ b/src/modules/parseTaskText.ts @@ -1,10 +1,31 @@ import {parseDate} from '../helpers/time/parseDate' import _priorities from '../models/constants/priorities.json' -const LABEL_PREFIX: string = '@' -const LIST_PREFIX: string = '#' -const PRIORITY_PREFIX: string = '!' -const ASSIGNEE_PREFIX: string = '+' +const VIKUNJA_PREFIXES: Prefixes = { + label: '*', + list: '+', + priority: '!', + assignee: '@', +} + +const TODOIST_PREFIXES: Prefixes = { + label: '@', + list: '#', + priority: '!', + assignee: '+', +} + +export enum PrefixMode { + Disabled = 'disabled', + Default = 'vikunja', + Todoist = 'todoist', +} + +export const PREFIXES = { + [PrefixMode.Disabled]: undefined, + [PrefixMode.Default]: VIKUNJA_PREFIXES, + [PrefixMode.Todoist]: TODOIST_PREFIXES, +} const priorities: Priorites = _priorities @@ -26,12 +47,19 @@ interface ParsedTaskText { assignees: string[], } +interface Prefixes { + label: string, + list: string, + priority: string, + assignee: string, +} + /** * Parses task text for dates, assignees, labels, lists, priorities and returns an object with all found intents. * * @param text */ -export const parseTaskText = (text: string): ParsedTaskText => { +export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMode.Default): ParsedTaskText => { const result: ParsedTaskText = { text: text, date: null, @@ -41,20 +69,25 @@ export const parseTaskText = (text: string): ParsedTaskText => { assignees: [], } - result.labels = getItemsFromPrefix(text, LABEL_PREFIX) + const prefixes = PREFIXES[prefixesMode] + if (prefixes === undefined) { + return result + } - const lists: string[] = getItemsFromPrefix(text, LIST_PREFIX) + result.labels = getItemsFromPrefix(text, prefixes.label) + + const lists: string[] = getItemsFromPrefix(text, prefixes.list) result.list = lists.length > 0 ? lists[0] : null - result.priority = getPriority(text) + result.priority = getPriority(text, prefixes.priority) - result.assignees = getItemsFromPrefix(text, ASSIGNEE_PREFIX) + result.assignees = getItemsFromPrefix(text, prefixes.assignee) const {newText, date} = parseDate(text) result.text = newText result.date = date - return cleanupResult(result) + return cleanupResult(result, prefixes) } const getItemsFromPrefix = (text: string, prefix: string): string[] => { @@ -82,8 +115,8 @@ const getItemsFromPrefix = (text: string, prefix: string): string[] => { return Array.from(new Set(items)) } -const getPriority = (text: string): number | null => { - const ps = getItemsFromPrefix(text, PRIORITY_PREFIX) +const getPriority = (text: string, prefix: string): number | null => { + const ps = getItemsFromPrefix(text, prefix) if (ps.length === 0) { return null } @@ -112,11 +145,11 @@ const cleanupItemText = (text: string, items: string[], prefix: string): string return text } -const cleanupResult = (result: ParsedTaskText): ParsedTaskText => { - result.text = cleanupItemText(result.text, result.labels, LABEL_PREFIX) - result.text = result.list !== null ? cleanupItemText(result.text, [result.list], LIST_PREFIX) : result.text - result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], PRIORITY_PREFIX) : result.text - result.text = cleanupItemText(result.text, result.assignees, ASSIGNEE_PREFIX) +const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => { + result.text = cleanupItemText(result.text, result.labels, prefixes.label) + result.text = result.list !== null ? cleanupItemText(result.text, [result.list], prefixes.list) : result.text + result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text + result.text = cleanupItemText(result.text, result.assignees, prefixes.assignee) result.text = result.text.trim() return result diff --git a/src/views/user/Settings.vue b/src/views/user/Settings.vue index 580b2e98..8709fbcd 100644 --- a/src/views/user/Settings.vue +++ b/src/views/user/Settings.vue @@ -77,6 +77,18 @@ +
+ +