diff --git a/src/components/tasks/partials/quick-add-magic.vue b/src/components/tasks/partials/quick-add-magic.vue index c28c6a7c8..00ed7f928 100644 --- a/src/components/tasks/partials/quick-add-magic.vue +++ b/src/components/tasks/partials/quick-add-magic.vue @@ -65,6 +65,21 @@
  • 17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})
  • {{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}

    + +

    {{ $t('task.quickAddMagic.repeats') }}

    +

    {{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}

    +

    {{ $t('misc.forExample') }}

    + diff --git a/src/helpers/time/parseDate.ts b/src/helpers/time/parseDate.ts index 0994b5d26..34e6ef0f3 100644 --- a/src/helpers/time/parseDate.ts +++ b/src/helpers/time/parseDate.ts @@ -135,18 +135,18 @@ export const getDateFromText = (text: string, now: Date = new Date()) => { if (result === null) { // 3. Try parsing the date as "27/01" or "01/27" - const monthNumericRegex:RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig + const monthNumericRegex: RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig results = monthNumericRegex.exec(text) // Put the year before or after the date, depending on what works result = results === null ? null : `${now.getFullYear()}/${results[0]}` - if(result === null) { + if (result === null) { return { foundText, date: null, } } - + foundText = results === null ? '' : results[0] if (result === null || isNaN(new Date(result).getTime())) { result = results === null ? null : `${results[0]}/${now.getFullYear()}` @@ -280,7 +280,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => { if (foundText.endsWith(' ')) { foundText = foundText.substr(0, foundText.length - 1) } - + return { foundText: foundText, date: date, @@ -301,12 +301,12 @@ const getDayFromText = (text: string) => { const date = new Date(now) const day = parseInt(results[0]) date.setDate(day) - + // If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the // date to the next month, but the first. // This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after // setting it for the first time and set it again if it isn't - that would mean the month overflowed. - if(day === 31 && date.getDate() !== day) { + if (day === 31 && date.getDate() !== day) { date.setDate(day) } diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 4a939660c..8a26c3a1e 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -471,7 +471,8 @@ "close": "Close", "download": "Download", "showMenu": "Show the menu", - "hideMenu": "Hide the menu" + "hideMenu": "Hide the menu", + "forExample": "For example:" }, "input": { "resetColor": "Reset Color", @@ -720,7 +721,9 @@ "dateWeekday": "any weekday, will use the next date with that date", "dateCurrentYear": "will use the current year", "dateNth": "will use the {day}th of the current month", - "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time." + "dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.", + "repeats": "Repeating tasks", + "repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)." } }, "team": { diff --git a/src/modules/parseTaskText.test.js b/src/modules/parseTaskText.test.js index 885f792d3..e68b1e834 100644 --- a/src/modules/parseTaskText.test.js +++ b/src/modules/parseTaskText.test.js @@ -7,14 +7,14 @@ 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') @@ -510,4 +510,54 @@ describe('Parse Task Text', () => { expect(result.assignees[0]).toBe('user with long name') }) }) + + describe('Recurring Dates', () => { + const cases = { + 'every 1 hour': {type: 'hours', amount: 1}, + 'every hour': {type: 'hours', amount: 1}, + 'every 5 hours': {type: 'hours', amount: 5}, + 'every 12 hours': {type: 'hours', amount: 12}, + 'every day': {type: 'days', amount: 1}, + 'every 1 day': {type: 'days', amount: 1}, + 'every 2 days': {type: 'days', amount: 2}, + 'every week': {type: 'weeks', amount: 1}, + 'every 1 week': {type: 'weeks', amount: 1}, + 'every 3 weeks': {type: 'weeks', amount: 3}, + 'every month': {type: 'months', amount: 1}, + 'every 1 month': {type: 'months', amount: 1}, + 'every 2 months': {type: 'months', amount: 2}, + 'every year': {type: 'years', amount: 1}, + 'every 1 year': {type: 'years', amount: 1}, + 'every 4 years': {type: 'years', amount: 4}, + 'anually': {type: 'years', amount: 1}, + 'bianually': {type: 'months', amount: 6}, + 'semiannually': {type: 'months', amount: 6}, + 'biennially': {type: 'years', amount: 2}, + 'daily': {type: 'days', amount: 1}, + 'hourly': {type: 'hours', amount: 1}, + 'monthly': {type: 'months', amount: 1}, + 'weekly': {type: 'weeks', amount: 1}, + 'yearly': {type: 'years', amount: 1}, + 'every one hour': {type: 'hours', amount: 1}, // maybe unnesecary but better to include it for completeness sake + 'every two hours': {type: 'hours', amount: 2}, + 'every three hours': {type: 'hours', amount: 3}, + 'every four hours': {type: 'hours', amount: 4}, + 'every five hours': {type: 'hours', amount: 5}, + 'every six hours': {type: 'hours', amount: 6}, + 'every seven hours': {type: 'hours', amount: 7}, + 'every eight hours': {type: 'hours', amount: 8}, + 'every nine hours': {type: 'hours', amount: 9}, + 'every ten hours': {type: 'hours', amount: 10}, + } + + for (const c in cases) { + it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => { + const result = parseTaskText(`Lorem Ipsum ${c}`) + + expect(result.text).toBe('Lorem Ipsum') + expect(result.repeats.type).toBe(cases[c].type) + expect(result.repeats.amount).toBe(cases[c].amount) + }) + } + }) }) diff --git a/src/modules/parseTaskText.ts b/src/modules/parseTaskText.ts index d3fbf84b6..30434f004 100644 --- a/src/modules/parseTaskText.ts +++ b/src/modules/parseTaskText.ts @@ -38,6 +38,24 @@ interface Priorites { DO_NOW: number, } +enum RepeatType { + Hours = 'hours', + Days = 'days', + Weeks = 'weeks', + Months = 'months', + Years = 'years', +} + +interface Repeats { + type: RepeatType, + amount: number, +} + +interface repeatParsedResult { + textWithoutMatched: string, + repeats: Repeats | null, +} + interface ParsedTaskText { text: string, date: Date | null, @@ -45,6 +63,7 @@ interface ParsedTaskText { list: string | null, priority: number | null, assignees: string[], + repeats: Repeats | null, } interface Prefixes { @@ -67,6 +86,7 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod list: null, priority: null, assignees: [], + repeats: null, } const prefixes = PREFIXES[prefixesMode] @@ -83,7 +103,11 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod result.assignees = getItemsFromPrefix(text, prefixes.assignee) - const {newText, date} = parseDate(text) + const {textWithoutMatched, repeats} = getRepeats(text) + result.text = textWithoutMatched + result.repeats = repeats + + const {newText, date} = parseDate(result.text) result.text = newText result.date = date @@ -132,6 +156,113 @@ const getPriority = (text: string, prefix: string): number | null => { return null } +const getRepeats = (text: string): repeatParsedResult => { + const regex = /((every|each) (([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten) )?(hours?|days?|weeks?|months?|years?))|anually|bianually|semiannually|biennially|daily|hourly|monthly|weekly|yearly/ig + const results = regex.exec(text) + if (results === null) { + return { + textWithoutMatched: text, + repeats: null, + } + } + + let amount = 1 + switch (results[3] ? results[3].trim() : undefined) { + case 'one': + amount = 1 + break + case 'two': + amount = 2 + break + case 'three': + amount = 3 + break + case 'four': + amount = 4 + break + case 'five': + amount = 5 + break + case 'six': + amount = 6 + break + case 'seven': + amount = 7 + break + case 'eight': + amount = 8 + break + case 'nine': + amount = 9 + break + case 'ten': + amount = 10 + break + default: + amount = results[3] ? parseInt(results[3]) : 1 + } + let type: RepeatType = RepeatType.Hours + + switch (results[0]) { + case 'biennially': + type = RepeatType.Years + amount = 2 + break + case 'bianually': + case 'semiannually': + type = RepeatType.Months + amount = 6 + break + case 'yearly': + case 'anually': + type = RepeatType.Years + break + case 'daily': + type = RepeatType.Days + break + case 'hourly': + type = RepeatType.Hours + break + case 'monthly': + type = RepeatType.Months + break + case 'weekly': + type = RepeatType.Weeks + break + default: + switch (results[5]) { + case 'hour': + case 'hours': + type = RepeatType.Hours + break + case 'day': + case 'days': + type = RepeatType.Days + break + case 'week': + case 'weeks': + type = RepeatType.Weeks + break + case 'month': + case 'months': + type = RepeatType.Months + break + case 'year': + case 'years': + type = RepeatType.Years + break + } + } + + return { + textWithoutMatched: text.replace(results[0], ''), + repeats: { + amount, + type, + }, + } +} + const cleanupItemText = (text: string, items: string[], prefix: string): string => { items.forEach(l => { text = text diff --git a/src/services/task.js b/src/services/task.js index 361d08dd0..c17bc2ef4 100644 --- a/src/services/task.js +++ b/src/services/task.js @@ -69,7 +69,7 @@ export default class TaskService extends AbstractService { // Make the repeating amount to seconds let repeatAfterSeconds = 0 - if (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0) { + if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) { switch (model.repeatAfter.type) { case 'hours': repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 diff --git a/src/store/modules/tasks.js b/src/store/modules/tasks.js index 70e5370ca..8d29cc890 100644 --- a/src/store/modules/tasks.js +++ b/src/store/modules/tasks.js @@ -292,6 +292,7 @@ export default { bucketId: bucketId || 0, position, }) + task.repeatAfter = parsedTask.repeats const taskService = new TaskService() const createdTask = await taskService.create(task)