feat: recurring for quick add magic (#1105)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/1105 Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de> Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
This commit is contained in:
parent
c8029ec3c4
commit
8b8e413af0
7 changed files with 213 additions and 13 deletions
|
@ -65,6 +65,21 @@
|
||||||
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
|
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
|
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
|
||||||
|
|
||||||
|
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
|
||||||
|
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
|
||||||
|
<p>{{ $t('misc.forExample') }}</p>
|
||||||
|
<ul>
|
||||||
|
<!-- Not localized because these only work in english -->
|
||||||
|
<li>Every day</li>
|
||||||
|
<li>Every 3 days</li>
|
||||||
|
<li>Every week</li>
|
||||||
|
<li>Every 2 weeks</li>
|
||||||
|
<li>Every month</li>
|
||||||
|
<li>Every 6 months</li>
|
||||||
|
<li>Every year</li>
|
||||||
|
<li>Every 2 years</li>
|
||||||
|
</ul>
|
||||||
</card>
|
</card>
|
||||||
</modal>
|
</modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -135,18 +135,18 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
// 3. Try parsing the date as "27/01" or "01/27"
|
// 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)
|
results = monthNumericRegex.exec(text)
|
||||||
|
|
||||||
// Put the year before or after the date, depending on what works
|
// Put the year before or after the date, depending on what works
|
||||||
result = results === null ? null : `${now.getFullYear()}/${results[0]}`
|
result = results === null ? null : `${now.getFullYear()}/${results[0]}`
|
||||||
if(result === null) {
|
if (result === null) {
|
||||||
return {
|
return {
|
||||||
foundText,
|
foundText,
|
||||||
date: null,
|
date: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foundText = results === null ? '' : results[0]
|
foundText = results === null ? '' : results[0]
|
||||||
if (result === null || isNaN(new Date(result).getTime())) {
|
if (result === null || isNaN(new Date(result).getTime())) {
|
||||||
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
|
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
|
||||||
|
@ -280,7 +280,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
|
||||||
if (foundText.endsWith(' ')) {
|
if (foundText.endsWith(' ')) {
|
||||||
foundText = foundText.substr(0, foundText.length - 1)
|
foundText = foundText.substr(0, foundText.length - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
foundText: foundText,
|
foundText: foundText,
|
||||||
date: date,
|
date: date,
|
||||||
|
@ -301,12 +301,12 @@ const getDayFromText = (text: string) => {
|
||||||
const date = new Date(now)
|
const date = new Date(now)
|
||||||
const day = parseInt(results[0])
|
const day = parseInt(results[0])
|
||||||
date.setDate(day)
|
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
|
// 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.
|
// 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
|
// 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.
|
// 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)
|
date.setDate(day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -471,7 +471,8 @@
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"showMenu": "Show the menu",
|
"showMenu": "Show the menu",
|
||||||
"hideMenu": "Hide the menu"
|
"hideMenu": "Hide the menu",
|
||||||
|
"forExample": "For example:"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"resetColor": "Reset Color",
|
"resetColor": "Reset Color",
|
||||||
|
@ -720,7 +721,9 @@
|
||||||
"dateWeekday": "any weekday, will use the next date with that date",
|
"dateWeekday": "any weekday, will use the next date with that date",
|
||||||
"dateCurrentYear": "will use the current year",
|
"dateCurrentYear": "will use the current year",
|
||||||
"dateNth": "will use the {day}th of the current month",
|
"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": {
|
"team": {
|
||||||
|
|
|
@ -7,14 +7,14 @@ describe('Parse Task Text', () => {
|
||||||
it('should return text with no intents as is', () => {
|
it('should return text with no intents as is', () => {
|
||||||
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
|
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not parse text when disabled', () => {
|
it('should not parse text when disabled', () => {
|
||||||
const text = 'Lorem Ipsum today *label +list !2 @user'
|
const text = 'Lorem Ipsum today *label +list !2 @user'
|
||||||
const result = parseTaskText(text, 'disabled')
|
const result = parseTaskText(text, 'disabled')
|
||||||
|
|
||||||
expect(result.text).toBe(text)
|
expect(result.text).toBe(text)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should parse text in todoist mode when configured', () => {
|
it('should parse text in todoist mode when configured', () => {
|
||||||
const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', 'todoist')
|
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')
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -38,6 +38,24 @@ interface Priorites {
|
||||||
DO_NOW: number,
|
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 {
|
interface ParsedTaskText {
|
||||||
text: string,
|
text: string,
|
||||||
date: Date | null,
|
date: Date | null,
|
||||||
|
@ -45,6 +63,7 @@ interface ParsedTaskText {
|
||||||
list: string | null,
|
list: string | null,
|
||||||
priority: number | null,
|
priority: number | null,
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
|
repeats: Repeats | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Prefixes {
|
interface Prefixes {
|
||||||
|
@ -67,6 +86,7 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
|
||||||
list: null,
|
list: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
assignees: [],
|
assignees: [],
|
||||||
|
repeats: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefixes = PREFIXES[prefixesMode]
|
const prefixes = PREFIXES[prefixesMode]
|
||||||
|
@ -83,7 +103,11 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
|
||||||
|
|
||||||
result.assignees = getItemsFromPrefix(text, prefixes.assignee)
|
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.text = newText
|
||||||
result.date = date
|
result.date = date
|
||||||
|
|
||||||
|
@ -132,6 +156,113 @@ const getPriority = (text: string, prefix: string): number | null => {
|
||||||
return 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 => {
|
const cleanupItemText = (text: string, items: string[], prefix: string): string => {
|
||||||
items.forEach(l => {
|
items.forEach(l => {
|
||||||
text = text
|
text = text
|
||||||
|
|
|
@ -69,7 +69,7 @@ export default class TaskService extends AbstractService {
|
||||||
|
|
||||||
// Make the repeating amount to seconds
|
// Make the repeating amount to seconds
|
||||||
let repeatAfterSeconds = 0
|
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) {
|
switch (model.repeatAfter.type) {
|
||||||
case 'hours':
|
case 'hours':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60
|
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60
|
||||||
|
|
|
@ -292,6 +292,7 @@ export default {
|
||||||
bucketId: bucketId || 0,
|
bucketId: bucketId || 0,
|
||||||
position,
|
position,
|
||||||
})
|
})
|
||||||
|
task.repeatAfter = parsedTask.repeats
|
||||||
|
|
||||||
const taskService = new TaskService()
|
const taskService = new TaskService()
|
||||||
const createdTask = await taskService.create(task)
|
const createdTask = await taskService.create(task)
|
||||||
|
|
Loading…
Reference in a new issue