diff --git a/frontend/src/modules/parseTaskText.ts b/frontend/src/modules/parseTaskText.ts deleted file mode 100644 index 8e1fbb045..000000000 --- a/frontend/src/modules/parseTaskText.ts +++ /dev/null @@ -1,306 +0,0 @@ -import {parseDate} from '../helpers/time/parseDate' -import {PRIORITIES} from '@/constants/priorities' -import {REPEAT_TYPES, type IRepeatAfter, type IRepeatType} from '@/types/IRepeatAfter' - -const VIKUNJA_PREFIXES: Prefixes = { - label: '*', - project: '+', - priority: '!', - assignee: '@', -} - -const TODOIST_PREFIXES: Prefixes = { - label: '@', - project: '#', - 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, -} - -interface repeatParsedResult { - textWithoutMatched: string, - repeats: IRepeatAfter | null, -} - -export interface ParsedTaskText { - text: string, - date: Date | null, - labels: string[], - project: string | null, - priority: number | null, - assignees: string[], - repeats: IRepeatAfter | null, -} - -interface Prefixes { - label: string, - project: string, - priority: string, - assignee: string, -} - -/** - * Parses task text for dates, assignees, labels, projects, priorities and returns an object with all found intents. - * - * @param text - */ -export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMode.Default, now: Date = new Date()): ParsedTaskText => { - const result: ParsedTaskText = { - text: text, - date: null, - labels: [], - project: null, - priority: null, - assignees: [], - repeats: null, - } - - const prefixes = PREFIXES[prefixesMode] - if (prefixes === undefined) { - return result - } - - result.labels = getLabelsFromPrefix(text, prefixesMode) ?? [] - result.text = cleanupItemText(result.text, result.labels, prefixes.label) - - result.project = getProjectFromPrefix(result.text, prefixesMode) - result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text - - result.priority = getPriority(result.text, prefixes.priority) - result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text - - result.assignees = getItemsFromPrefix(result.text, prefixes.assignee) - - const {textWithoutMatched, repeats} = getRepeats(result.text) - result.text = textWithoutMatched - result.repeats = repeats - - const {newText, date} = parseDate(result.text, now) - result.text = newText - result.date = date - - return cleanupResult(result, prefixes) -} - -const getItemsFromPrefix = (text: string, prefix: string): string[] => { - const items: string[] = [] - - const itemParts = text.split(' ' + prefix) - if (text.startsWith(prefix)) { - const firstItem = text.split(prefix)[1] - itemParts.unshift(firstItem) - } - - itemParts.forEach((p, index) => { - // First part contains the rest - if (index < 1) { - return - } - - if (p.startsWith(prefix)) { - p = p.substring(1) - } - - let itemText - if (p.charAt(0) === '\'') { - itemText = p.split('\'')[1] - } else if (p.charAt(0) === '"') { - itemText = p.split('"')[1] - } else { - // Only until the next space - itemText = p.split(' ')[0] - } - - if (itemText !== '') { - items.push(itemText) - } - }) - - return Array.from(new Set(items)) -} - -export const getProjectFromPrefix = (text: string, prefixMode: PrefixMode): string | null => { - const projectPrefix = PREFIXES[prefixMode]?.project - if(typeof projectPrefix === 'undefined') { - return null - } - const projects: string[] = getItemsFromPrefix(text, projectPrefix) - return projects.length > 0 ? projects[0] : null -} - -export const getLabelsFromPrefix = (text: string, prefixMode: PrefixMode): string[] | null => { - const labelsPrefix = PREFIXES[prefixMode]?.label - if(typeof labelsPrefix === 'undefined') { - return null - } - return getItemsFromPrefix(text, labelsPrefix) -} - -const getPriority = (text: string, prefix: string): number | null => { - const ps = getItemsFromPrefix(text, prefix) - if (ps.length === 0) { - return null - } - - for (const p of ps) { - for (const pi of Object.values(PRIORITIES)) { - if (pi === parseInt(p)) { - return parseInt(p) - } - } - } - - 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?))|(annually|biannually|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[5] ? results[5].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[5] ? parseInt(results[5]) : 1 - } - let type: IRepeatType = REPEAT_TYPES.Hours - - switch (results[2]) { - case 'biennially': - type = REPEAT_TYPES.Years - amount = 2 - break - case 'biannually': - case 'semiannually': - type = REPEAT_TYPES.Months - amount = 6 - break - case 'yearly': - case 'annually': - type = REPEAT_TYPES.Years - break - case 'daily': - type = REPEAT_TYPES.Days - break - case 'hourly': - type = REPEAT_TYPES.Hours - break - case 'monthly': - type = REPEAT_TYPES.Months - break - case 'weekly': - type = REPEAT_TYPES.Weeks - break - default: - switch (results[7]) { - case 'hour': - case 'hours': - type = REPEAT_TYPES.Hours - break - case 'day': - case 'days': - type = REPEAT_TYPES.Days - break - case 'week': - case 'weeks': - type = REPEAT_TYPES.Weeks - break - case 'month': - case 'months': - type = REPEAT_TYPES.Months - break - case 'year': - case 'years': - type = REPEAT_TYPES.Years - break - } - } - - let matchedText = results[0] - if(matchedText.endsWith(' ')) { - matchedText = matchedText.substring(0, matchedText.length - 1) - } - - return { - textWithoutMatched: text.replace(matchedText, ''), - repeats: { - amount, - type, - }, - } -} - -const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -export const cleanupItemText = (text: string, items: string[], prefix: string): string => { - items.forEach(l => { - if (l === '') { - return - } - const escaped = escapeRegExp(l) - text = text - .replace(new RegExp(`\\${prefix}'${escaped}' `, 'ig'), '') - .replace(new RegExp(`\\${prefix}'${escaped}'`, 'ig'), '') - .replace(new RegExp(`\\${prefix}"${escaped}" `, 'ig'), '') - .replace(new RegExp(`\\${prefix}"${escaped}"`, 'ig'), '') - .replace(new RegExp(`\\${prefix}${escaped} `, 'ig'), '') - .replace(new RegExp(`\\${prefix}${escaped}`, 'ig'), '') - }) - return text -} - -const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => { - result.text = cleanupItemText(result.text, result.labels, prefixes.label) - result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text - result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text - // Not removing assignees to avoid removing @text where the user does not exist - result.text = result.text.trim() - - return result -} diff --git a/frontend/src/helpers/time/parseDate.ts b/frontend/src/modules/parseTaskText/dateParser.ts similarity index 96% rename from frontend/src/helpers/time/parseDate.ts rename to frontend/src/modules/parseTaskText/dateParser.ts index d74e55ecb..e1621d324 100644 --- a/frontend/src/helpers/time/parseDate.ts +++ b/frontend/src/modules/parseTaskText/dateParser.ts @@ -1,6 +1,6 @@ -import {calculateDayInterval} from './calculateDayInterval' -import {calculateNearestHours} from './calculateNearestHours' -import {replaceAll} from '../replaceAll' +import {calculateDayInterval} from '@/helpers/time/calculateDayInterval' +import {calculateNearestHours} from '@/helpers/time/calculateNearestHours' +import {replaceAll} from '@/helpers/replaceAll' export interface dateParseResult { newText: string, @@ -196,7 +196,7 @@ export const getDateFromText = (text: string, now: Date = new Date()) => { result = `${month}/${day}/${tmp_year}` result = !isNaN(new Date(result).getTime()) ? result : `${day}/${month}/${tmp_year}` result = !isNaN(new Date(result).getTime()) ? result : null - + if(result !== null){ foundText = found break @@ -212,7 +212,7 @@ export const getDateFromText = (text: string, now: Date = new Date()) => { foundText = results === null ? '' : results[0].trim() containsYear = false } - + if (result === null) { return { foundText, @@ -328,7 +328,7 @@ const getDateFromWeekday = (text: string, date: Date = new Date()): dateFoundRes const distance: number = (day + 7 - currentDay) % 7 date.setDate(date.getDate() + distance) - // This a space at the end of the found text to not break parsing suffix strings like "at 14:00" in cases where the + // This a space at the end of the found text to not break parsing suffix strings like "at 14:00" in cases where the // matched string comes with a space at the end (last part of the regex). let foundText = results[0] if (foundText.endsWith(' ')) { @@ -357,9 +357,9 @@ const getDayFromText = (text: string, now: Date = new Date()) => { const day = parseInt(results[0]) date.setDate(day) - // If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days, + // If the parsed day is the 31st (or 29+ and the next month is february) 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 + // 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. while (date < now) { date.setMonth(date.getMonth() + 1) diff --git a/frontend/src/modules/parseTaskText/index.ts b/frontend/src/modules/parseTaskText/index.ts new file mode 100644 index 000000000..134f96ee2 --- /dev/null +++ b/frontend/src/modules/parseTaskText/index.ts @@ -0,0 +1,5 @@ +export {parseTaskText} from './parseTaskText' +export {PrefixMode, PREFIXES} from './prefixes' +export {getLabelsFromPrefix, getProjectFromPrefix} from './prefixParser' +export {cleanupItemText} from './textCleanup' +export type {ParsedTaskText} from './types' diff --git a/frontend/src/modules/parseTaskText.test.ts b/frontend/src/modules/parseTaskText/parseTaskText.test.ts similarity index 99% rename from frontend/src/modules/parseTaskText.test.ts rename to frontend/src/modules/parseTaskText/parseTaskText.test.ts index 2df6cc41c..1137567e1 100644 --- a/frontend/src/modules/parseTaskText.test.ts +++ b/frontend/src/modules/parseTaskText/parseTaskText.test.ts @@ -1,8 +1,9 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {ParsedTaskText, parseTaskText, PrefixMode} from './parseTaskText' -import {parseDate} from '../helpers/time/parseDate' -import {calculateDayInterval} from '../helpers/time/calculateDayInterval' +import {parseTaskText, PrefixMode} from '.' +import type {ParsedTaskText} from '.' +import {parseDate} from './dateParser' +import {calculateDayInterval} from '@/helpers/time/calculateDayInterval' import {PRIORITIES} from '@/constants/priorities' import {MILLISECONDS_A_DAY} from '@/constants/date' import type {IRepeatAfter} from '@/types/IRepeatAfter' diff --git a/frontend/src/modules/parseTaskText/parseTaskText.ts b/frontend/src/modules/parseTaskText/parseTaskText.ts new file mode 100644 index 000000000..3a65989d3 --- /dev/null +++ b/frontend/src/modules/parseTaskText/parseTaskText.ts @@ -0,0 +1,50 @@ +import {parseDate} from './dateParser' +import {PREFIXES, PrefixMode} from './prefixes' +import {getItemsFromPrefix, getLabelsFromPrefix, getProjectFromPrefix} from './prefixParser' +import {getPriority} from './priorityParser' +import {getRepeats} from './repeatParser' +import {cleanupItemText, cleanupResult} from './textCleanup' +import type {ParsedTaskText} from './types' + +/** + * Parses task text for dates, assignees, labels, projects, priorities and returns an object with all found intents. + * + * @param text + */ +export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMode.Default, now: Date = new Date()): ParsedTaskText => { + const result: ParsedTaskText = { + text: text, + date: null, + labels: [], + project: null, + priority: null, + assignees: [], + repeats: null, + } + + const prefixes = PREFIXES[prefixesMode] + if (prefixes === undefined) { + return result + } + + result.labels = getLabelsFromPrefix(text, prefixesMode) ?? [] + result.text = cleanupItemText(result.text, result.labels, prefixes.label) + + result.project = getProjectFromPrefix(result.text, prefixesMode) + result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text + + result.priority = getPriority(result.text, prefixes.priority) + result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text + + result.assignees = getItemsFromPrefix(result.text, prefixes.assignee) + + const {textWithoutMatched, repeats} = getRepeats(result.text) + result.text = textWithoutMatched + result.repeats = repeats + + const {newText, date} = parseDate(result.text, now) + result.text = newText + result.date = date + + return cleanupResult(result, prefixes) +} diff --git a/frontend/src/modules/parseTaskText/prefixParser.ts b/frontend/src/modules/parseTaskText/prefixParser.ts new file mode 100644 index 000000000..9dfc6e95c --- /dev/null +++ b/frontend/src/modules/parseTaskText/prefixParser.ts @@ -0,0 +1,55 @@ +import {PREFIXES, PrefixMode} from './prefixes' + +export const getItemsFromPrefix = (text: string, prefix: string): string[] => { + const items: string[] = [] + + const itemParts = text.split(' ' + prefix) + if (text.startsWith(prefix)) { + const firstItem = text.split(prefix)[1] + itemParts.unshift(firstItem) + } + + itemParts.forEach((p, index) => { + // First part contains the rest + if (index < 1) { + return + } + + if (p.startsWith(prefix)) { + p = p.substring(1) + } + + let itemText + if (p.charAt(0) === '\'') { + itemText = p.split('\'')[1] + } else if (p.charAt(0) === '"') { + itemText = p.split('"')[1] + } else { + // Only until the next space + itemText = p.split(' ')[0] + } + + if (itemText !== '') { + items.push(itemText) + } + }) + + return Array.from(new Set(items)) +} + +export const getProjectFromPrefix = (text: string, prefixMode: PrefixMode): string | null => { + const projectPrefix = PREFIXES[prefixMode]?.project + if(typeof projectPrefix === 'undefined') { + return null + } + const projects: string[] = getItemsFromPrefix(text, projectPrefix) + return projects.length > 0 ? projects[0] : null +} + +export const getLabelsFromPrefix = (text: string, prefixMode: PrefixMode): string[] | null => { + const labelsPrefix = PREFIXES[prefixMode]?.label + if(typeof labelsPrefix === 'undefined') { + return null + } + return getItemsFromPrefix(text, labelsPrefix) +} diff --git a/frontend/src/modules/parseTaskText/prefixes.ts b/frontend/src/modules/parseTaskText/prefixes.ts new file mode 100644 index 000000000..9b198e009 --- /dev/null +++ b/frontend/src/modules/parseTaskText/prefixes.ts @@ -0,0 +1,27 @@ +import type {Prefixes} from './types' + +const VIKUNJA_PREFIXES: Prefixes = { + label: '*', + project: '+', + priority: '!', + assignee: '@', +} + +const TODOIST_PREFIXES: Prefixes = { + label: '@', + project: '#', + 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, +} diff --git a/frontend/src/modules/parseTaskText/priorityParser.ts b/frontend/src/modules/parseTaskText/priorityParser.ts new file mode 100644 index 000000000..f1dc2db2e --- /dev/null +++ b/frontend/src/modules/parseTaskText/priorityParser.ts @@ -0,0 +1,19 @@ +import {PRIORITIES} from '@/constants/priorities' +import {getItemsFromPrefix} from './prefixParser' + +export const getPriority = (text: string, prefix: string): number | null => { + const ps = getItemsFromPrefix(text, prefix) + if (ps.length === 0) { + return null + } + + for (const p of ps) { + for (const pi of Object.values(PRIORITIES)) { + if (pi === parseInt(p)) { + return parseInt(p) + } + } + } + + return null +} diff --git a/frontend/src/modules/parseTaskText/repeatParser.ts b/frontend/src/modules/parseTaskText/repeatParser.ts new file mode 100644 index 000000000..1b061ee7d --- /dev/null +++ b/frontend/src/modules/parseTaskText/repeatParser.ts @@ -0,0 +1,114 @@ +import {REPEAT_TYPES, type IRepeatType} from '@/types/IRepeatAfter' +import type {repeatParsedResult} from './types' + +export 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?))|(annually|biannually|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[5] ? results[5].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[5] ? parseInt(results[5]) : 1 + } + let type: IRepeatType = REPEAT_TYPES.Hours + + switch (results[2]) { + case 'biennially': + type = REPEAT_TYPES.Years + amount = 2 + break + case 'biannually': + case 'semiannually': + type = REPEAT_TYPES.Months + amount = 6 + break + case 'yearly': + case 'annually': + type = REPEAT_TYPES.Years + break + case 'daily': + type = REPEAT_TYPES.Days + break + case 'hourly': + type = REPEAT_TYPES.Hours + break + case 'monthly': + type = REPEAT_TYPES.Months + break + case 'weekly': + type = REPEAT_TYPES.Weeks + break + default: + switch (results[7]) { + case 'hour': + case 'hours': + type = REPEAT_TYPES.Hours + break + case 'day': + case 'days': + type = REPEAT_TYPES.Days + break + case 'week': + case 'weeks': + type = REPEAT_TYPES.Weeks + break + case 'month': + case 'months': + type = REPEAT_TYPES.Months + break + case 'year': + case 'years': + type = REPEAT_TYPES.Years + break + } + } + + let matchedText = results[0] + if(matchedText.endsWith(' ')) { + matchedText = matchedText.substring(0, matchedText.length - 1) + } + + return { + textWithoutMatched: text.replace(matchedText, ''), + repeats: { + amount, + type, + }, + } +} diff --git a/frontend/src/modules/parseTaskText/textCleanup.ts b/frontend/src/modules/parseTaskText/textCleanup.ts new file mode 100644 index 000000000..05d5ab6d7 --- /dev/null +++ b/frontend/src/modules/parseTaskText/textCleanup.ts @@ -0,0 +1,30 @@ +import type {ParsedTaskText, Prefixes} from './types' + +const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const cleanupItemText = (text: string, items: string[], prefix: string): string => { + items.forEach(l => { + if (l === '') { + return + } + const escaped = escapeRegExp(l) + text = text + .replace(new RegExp(`\\${prefix}'${escaped}' `, 'ig'), '') + .replace(new RegExp(`\\${prefix}'${escaped}'`, 'ig'), '') + .replace(new RegExp(`\\${prefix}"${escaped}" `, 'ig'), '') + .replace(new RegExp(`\\${prefix}"${escaped}"`, 'ig'), '') + .replace(new RegExp(`\\${prefix}${escaped} `, 'ig'), '') + .replace(new RegExp(`\\${prefix}${escaped}`, 'ig'), '') + }) + return text +} + +export const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => { + result.text = cleanupItemText(result.text, result.labels, prefixes.label) + result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text + result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text + // Not removing assignees to avoid removing @text where the user does not exist + result.text = result.text.trim() + + return result +} diff --git a/frontend/src/modules/parseTaskText/types.ts b/frontend/src/modules/parseTaskText/types.ts new file mode 100644 index 000000000..c8fdb5796 --- /dev/null +++ b/frontend/src/modules/parseTaskText/types.ts @@ -0,0 +1,23 @@ +import type {IRepeatAfter} from '@/types/IRepeatAfter' + +export interface repeatParsedResult { + textWithoutMatched: string, + repeats: IRepeatAfter | null, +} + +export interface ParsedTaskText { + text: string, + date: Date | null, + labels: string[], + project: string | null, + priority: number | null, + assignees: string[], + repeats: IRepeatAfter | null, +} + +export interface Prefixes { + label: string, + project: string, + priority: string, + assignee: string, +}