Files
webmail/lib/task-due-parser.ts
T
Linus Rath 19e5ca5077 fix: list the tasks of task-only lists and turn tasks on by default
The tasks view filtered tasks by the grid's calendar selection, which
never holds a task-only list such as "To-do". Those tasks never showed
while the sidebar counted them, and a task moved into To-do vanished on
save. The tasks view now has its own task lists in the sidebar, ticked
by default, and the grid's selection is left alone.

- tasks are on by default; an "off" the user never chose turns on, one
  they chose stays
- new tasks go to the first task-only list
- "Add a task…" reads a date at the end of the title (tomorrow, Friday,
  12.11.) into the due date and shows it as a chip first
- the task status says "Not started" and "In progress"

filterTasksForList now takes the unticked task lists instead of the
selected calendars, so task-plugin-surfaces passes the unticked list and
task-list-dates no longer passes selectedCalendarIds.
2026-11-04 08:02:27 +01:00

161 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { addDays, format, isValid } from "date-fns";
/**
* Reads a due date from the end of a quick task title: "Call the plumber
* tomorrow" is "Call the plumber", due tomorrow. Takes the words for today,
* tomorrow and the day after, the weekdays and numeric dates (12.11.,
* 11/12, 2026-11-12) in the app's language, from Intl so every language the
* app has works, and in English. Only the end of the title counts, and a
* title that is nothing but the date stays as it is.
*/
export interface ParsedTaskDue {
/** The title without the date words. */
title: string;
/** yyyy-MM-dd */
due: string;
/** The words taken from the title, as typed. */
matched: string;
}
// Languages written without spaces between words: the date may follow the
// title directly.
const NO_SPACE_LANGUAGES = new Set(["ja", "zh", "th"]);
// Short words before the date that belong to it ("on Friday", "am Freitag").
const LEAD_WORDS: Record<string, string[]> = {
en: ["on", "by", "due", "until"],
de: ["am", "bis", "bis zum", "zum"],
};
function baseLanguage(locale: string): string {
return locale.toLowerCase().split(/[-_]/)[0];
}
function normalize(text: string): string {
return text.toLocaleLowerCase().normalize("NFC").replace(/[’']/g, "'");
}
function relativeDayWords(locale: string): Array<{ word: string; offset: number }> {
const out: Array<{ word: string; offset: number }> = [];
try {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const offset of [0, 1, 2]) {
const word = rtf.format(offset, "day");
// "in 2 days" is not a word for the day after tomorrow.
if (!/\d/.test(word)) out.push({ word: normalize(word), offset });
}
} catch {
// An unknown locale has no words of its own.
}
return out;
}
function weekdayWords(locale: string): Array<{ word: string; weekday: number }> {
const out: Array<{ word: string; weekday: number }> = [];
try {
const fmt = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2026-11-01 is a Sunday.
for (let weekday = 0; weekday < 7; weekday++) {
out.push({ word: normalize(fmt.format(new Date(2026, 10, 1 + weekday))), weekday });
}
} catch {
// As above.
}
return out;
}
/** True when the locale writes the month before the day (en-US 11/12). */
function monthFirst(locale: string): boolean {
try {
const types = new Intl.DateTimeFormat(locale).formatToParts(new Date(2026, 10, 12)).map((p) => p.type);
return types.indexOf("month") < types.indexOf("day");
} catch {
return false;
}
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** The date `day.month[.year]` names, the next one from `today` when it has no year. */
function dateFrom(day: number, month: number, year: number | null, today: Date): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const fullYear = year === null ? today.getFullYear() : year < 100 ? 2000 + year : year;
let date = new Date(fullYear, month - 1, day);
if (!isValid(date) || date.getDate() !== day) return null;
if (year === null) {
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if (date < start) date = new Date(fullYear + 1, month - 1, day);
if (date.getDate() !== day) return null;
}
return date;
}
export function parseTaskDue(input: string, locale: string, today: Date = new Date()): ParsedTaskDue | null {
const text = input.trim();
if (!text) return null;
const lang = baseLanguage(locale);
const locales = lang === "en" ? [locale] : [locale, "en"];
const lower = normalize(text);
const spaceless = NO_SPACE_LANGUAGES.has(lang);
// The candidates, longest first so "day after tomorrow" wins over "tomorrow".
const candidates: Array<{ pattern: RegExp; resolve: (m: RegExpMatchArray) => Date | null }> = [];
const wordEnd = (word: string) => new RegExp(`${spaceless ? "" : "(?:^|\\s)"}(${escapeRegExp(word)})[.!]?$`, "u");
const words: Array<{ word: string; resolve: () => Date }> = [];
for (const loc of locales) {
for (const { word, offset } of relativeDayWords(loc)) {
words.push({ word, resolve: () => addDays(today, offset) });
}
for (const { word, weekday } of weekdayWords(loc)) {
words.push({ word, resolve: () => addDays(today, (weekday - today.getDay() + 7) % 7) });
}
}
words.sort((a, b) => b.word.length - a.word.length);
for (const { word, resolve } of words) {
candidates.push({ pattern: wordEnd(word), resolve });
}
// 2026-11-12
candidates.push({
pattern: /(?:^|\s)((\d{4})-(\d{1,2})-(\d{1,2}))$/u,
resolve: (m) => dateFrom(Number(m[4]), Number(m[3]), Number(m[2]), today),
});
// 12.11. / 12.11.2026 / 11/12 / 11/12/26: the order the locale writes.
const mFirst = monthFirst(locale);
candidates.push({
pattern: /(?:^|\s)((\d{1,2})[./](\d{1,2})(?:[./](\d{2}|\d{4}))?\.?)$/u,
resolve: (m) => {
const a = Number(m[2]);
const b = Number(m[3]);
const year = m[4] ? Number(m[4]) : null;
return mFirst ? dateFrom(b, a, year, today) : dateFrom(a, b, year, today);
},
});
for (const { pattern, resolve } of candidates) {
const match = lower.match(pattern);
if (!match || match.index === undefined) continue;
const date = resolve(match);
if (!date) continue;
// Where the date words start in the typed text (same length: only case changed).
let start = match.index + match[0].indexOf(match[1]);
let rest = text.slice(0, start).trimEnd();
// "on Friday": the lead word goes with the date.
for (const lead of LEAD_WORDS[lang] ?? LEAD_WORDS.en) {
const leadMatch = normalize(rest).match(new RegExp(`(?:^|\\s)${escapeRegExp(lead)}$`, "u"));
if (leadMatch && leadMatch.index !== undefined && rest.length > lead.length) {
const leadStart = leadMatch.index + leadMatch[0].length - lead.length;
rest = rest.slice(0, leadStart).trimEnd();
start = leadStart;
break;
}
}
if (!rest) return null;
return { title: rest, due: format(date, "yyyy-MM-dd"), matched: text.slice(start).trim() };
}
return null;
}