mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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.
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
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;
|
||
}
|