diff --git a/src/services/command.ts b/src/services/command.ts index b2cb9cd..2c798c7 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -13,6 +13,7 @@ import { Metrics } from './metrics'; import { ResponseQueue } from './response-queue'; import { randomTokenBase64Url, sha256Hex } from '../utils/crypto'; import { route as routeCommand } from './commands'; +import { parseNueva } from './commands/parsers/nueva'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -40,134 +41,6 @@ export class CommandService { static dbInstance: Database = db; private static readonly CTA_HELP: string = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`'; - private static parseNueva(message: string, mentionsNormalized: string[]): { - action: string; - description: string; - dueDate: string | null; - selfAssign: boolean; - } { - const parts = (message || '').trim().split(/\s+/); - const action = (parts[1] || '').toLowerCase(); - - // Zona horaria configurable (por defecto Europe/Madrid) - const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; - - // Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host - const ymdFromDateInTZ = (d: Date): string => { - const fmt = new Intl.DateTimeFormat('es-ES', { - timeZone: TZ, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(d); - const get = (t: string) => fmt.find(p => p.type === t)?.value || ''; - return `${get('year')}-${get('month')}-${get('day')}`; - }; - - const addDaysToYMD = (ymd: string, days: number): string => { - const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10)); - const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1)); - base.setUTCDate(base.getUTCDate() + days); - return ymdFromDateInTZ(base); - }; - - const todayYMD = ymdFromDateInTZ(new Date()); - - // Helpers para validar y normalizar fechas explícitas - const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0); - const daysInMonth = (y: number, m: number) => { - if (m === 2) return isLeap(y) ? 29 : 28; - return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]; - }; - const isValidYMD = (ymd: string): boolean => { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); - if (!m) return false; - const Y = parseInt(m[1], 10); - const MM = parseInt(m[2], 10); - const DD = parseInt(m[3], 10); - if (MM < 1 || MM > 12) return false; - const dim = daysInMonth(Y, MM); - if (!dim || DD < 1 || DD > dim) return false; - return true; - }; - const normalizeDateToken = (t: string): string | null => { - // YYYY-MM-DD - if (/^\d{4}-\d{2}-\d{2}$/.test(t)) { - return isValidYMD(t) ? t : null; - } - // YY-MM-DD -> 20YY-MM-DD - const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t); - if (m) { - const yy = parseInt(m[1], 10); - const mm = m[2]; - const dd = m[3]; - const yyyy = 2000 + yy; - const ymd = `${String(yyyy)}-${mm}-${dd}`; - return isValidYMD(ymd) ? ymd : null; - } - return null; - }; - - type DateCandidate = { index: number; ymd: string }; - const dateCandidates: DateCandidate[] = []; - const dateTokenIndexes = new Set(); - const selfTokenIndexes = new Set(); - let selfAssign = false; - - for (let i = 2; i < parts.length; i++) { - // Normalizar token: minúsculas y sin puntuación adyacente simple - const raw = parts[i]; - const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, ''); - - // Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY) - { - const norm = normalizeDateToken(low); - if (norm && norm >= todayYMD) { - dateCandidates.push({ index: i, ymd: norm }); - dateTokenIndexes.add(i); - continue; - } - } - - // Tokens naturales "hoy"/"mañana" (con o sin acento) - if (low === 'hoy') { - dateCandidates.push({ index: i, ymd: todayYMD }); - dateTokenIndexes.add(i); - continue; - } - if (low === 'mañana' || low === 'manana') { - dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) }); - dateTokenIndexes.add(i); - continue; - } - - // Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple) - if (low === 'yo' || low === '@yo') { - selfAssign = true; - selfTokenIndexes.add(i); - continue; - } - } - - const dueDate = dateCandidates.length > 0 - ? dateCandidates[dateCandidates.length - 1].ymd - : null; - - const isMentionToken = (token: string) => token.startsWith('@'); - - const descriptionTokens: string[] = []; - for (let i = 2; i < parts.length; i++) { - if (dateTokenIndexes.has(i)) continue; - if (selfTokenIndexes.has(i)) continue; - const token = parts[i]; - if (isMentionToken(token)) continue; - descriptionTokens.push(token); - } - - const description = descriptionTokens.join(' ').trim(); - - return { action, description, dueDate, selfAssign }; - } private static resolveTaskIdFromInput(n: number): number | null { const byCode = TaskService.getActiveTaskByDisplayCode(n); @@ -1112,7 +985,7 @@ export class CommandService { }); } - const { description, dueDate, selfAssign } = this.parseNueva(trimmed, mentionsNormalizedFromContext); + const { description, dueDate, selfAssign } = parseNueva(trimmed, mentionsNormalizedFromContext); // Asegurar creador const createdBy = ensureUserExists(context.sender, this.dbInstance); diff --git a/src/services/commands/parsers/nueva.ts b/src/services/commands/parsers/nueva.ts new file mode 100644 index 0000000..551a5eb --- /dev/null +++ b/src/services/commands/parsers/nueva.ts @@ -0,0 +1,128 @@ +export function parseNueva(message: string, _mentionsNormalized: string[]): { + action: string; + description: string; + dueDate: string | null; + selfAssign: boolean; +} { + const parts = (message || '').trim().split(/\s+/); + const action = (parts[1] || '').toLowerCase(); + + // Zona horaria configurable (por defecto Europe/Madrid) + const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; + + // Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host + const ymdFromDateInTZ = (d: Date): string => { + const fmt = new Intl.DateTimeFormat('es-ES', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(d); + const get = (t: string) => fmt.find(p => p.type === t)?.value || ''; + return `${get('year')}-${get('month')}-${get('day')}`; + }; + + const addDaysToYMD = (ymd: string, days: number): string => { + const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10)); + const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1)); + base.setUTCDate(base.getUTCDate() + days); + return ymdFromDateInTZ(base); + }; + + const todayYMD = ymdFromDateInTZ(new Date()); + + // Helpers para validar y normalizar fechas explícitas + const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0); + const daysInMonth = (y: number, m: number) => { + if (m === 2) return isLeap(y) ? 29 : 28; + return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]; + }; + const isValidYMD = (ymd: string): boolean => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + if (!m) return false; + const Y = parseInt(m[1], 10); + const MM = parseInt(m[2], 10); + const DD = parseInt(m[3], 10); + if (MM < 1 || MM > 12) return false; + const dim = daysInMonth(Y, MM); + if (!dim || DD < 1 || DD > dim) return false; + return true; + }; + const normalizeDateToken = (t: string): string | null => { + // YYYY-MM-DD + if (/^\d{4}-\d{2}-\d{2}$/.test(t)) { + return isValidYMD(t) ? t : null; + } + // YY-MM-DD -> 20YY-MM-DD + const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t); + if (m) { + const yy = parseInt(m[1], 10); + const mm = m[2]; + const dd = m[3]; + const yyyy = 2000 + yy; + const ymd = `${String(yyyy)}-${mm}-${dd}`; + return isValidYMD(ymd) ? ymd : null; + } + return null; + }; + + type DateCandidate = { index: number; ymd: string }; + const dateCandidates: DateCandidate[] = []; + const dateTokenIndexes = new Set(); + const selfTokenIndexes = new Set(); + let selfAssign = false; + + for (let i = 2; i < parts.length; i++) { + // Normalizar token: minúsculas y sin puntuación adyacente simple + const raw = parts[i]; + const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, ''); + + // Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY) + { + const norm = normalizeDateToken(low); + if (norm && norm >= todayYMD) { + dateCandidates.push({ index: i, ymd: norm }); + dateTokenIndexes.add(i); + continue; + } + } + + // Tokens naturales "hoy"/"mañana" (con o sin acento) + if (low === 'hoy') { + dateCandidates.push({ index: i, ymd: todayYMD }); + dateTokenIndexes.add(i); + continue; + } + if (low === 'mañana' || low === 'manana') { + dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) }); + dateTokenIndexes.add(i); + continue; + } + + // Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple) + if (low === 'yo' || low === '@yo') { + selfAssign = true; + selfTokenIndexes.add(i); + continue; + } + } + + const dueDate = dateCandidates.length > 0 + ? dateCandidates[dateCandidates.length - 1].ymd + : null; + + const isMentionToken = (token: string) => token.startsWith('@'); + + const descriptionTokens: string[] = []; + for (let i = 2; i < parts.length; i++) { + if (dateTokenIndexes.has(i)) continue; + if (selfTokenIndexes.has(i)) continue; + const token = parts[i]; + if (isMentionToken(token)) continue; + descriptionTokens.push(token); + } + + const description = descriptionTokens.join(' ').trim(); + + return { action, description, dueDate, selfAssign }; +}