feat: extraer parseNueva a módulo dedicado y usarlo desde CommandService

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 4 days ago
parent f7229d14d4
commit d591697402

@ -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<number>();
const selfTokenIndexes = new Set<number>();
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);

@ -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<number>();
const selfTokenIndexes = new Set<number>();
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 };
}
Loading…
Cancel
Save