From a006d0b4695e18395d2163e721c0892d091b0790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Rahir=20=28rar=29?= Date: Thu, 19 Sep 2024 17:25:03 +0200 Subject: [PATCH] [IMP] autofill: support date autofill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes odoo/o-spreadsheet#5011 Task: 4173602 Signed-off-by: Lucas Lefèvre (lul) --- src/registries/autofill_modifiers.ts | 26 +++- src/registries/autofill_rules.ts | 179 ++++++++++++++++++++++++- src/types/autofill.ts | 13 +- tests/autofill/autofill_plugin.test.ts | 174 ++++++++++++++++++++++-- 4 files changed, 379 insertions(+), 13 deletions(-) diff --git a/src/registries/autofill_modifiers.ts b/src/registries/autofill_modifiers.ts index 41a447eee2..439e1c4e20 100644 --- a/src/registries/autofill_modifiers.ts +++ b/src/registries/autofill_modifiers.ts @@ -1,3 +1,5 @@ +import { toJsDate } from "../functions/helpers"; +import { jsDateToNumber } from "../helpers"; import { evaluateLiteral } from "../helpers/cells"; import { formatValue } from "../helpers/format/format"; import { @@ -10,7 +12,7 @@ import { IncrementModifier, LiteralCell, } from "../types/index"; -import { AlphanumericIncrementModifier } from "./../types/autofill"; +import { AlphanumericIncrementModifier, DateIncrementModifier } from "./../types/autofill"; import { Registry } from "./registry"; /** @@ -55,6 +57,28 @@ autofillModifiersRegistry }; }, }) + .add("DATE_INCREMENT_MODIFIER", { + apply: (rule: DateIncrementModifier, data: AutofillData, getters: Getters) => { + const date = toJsDate(rule.current, getters.getLocale()); + date.setFullYear(date.getFullYear() + rule.increment.years || 0); + date.setMonth(date.getMonth() + rule.increment.months || 0); + date.setDate(date.getDate() + rule.increment.days || 0); + + const value = jsDateToNumber(date); + rule.current = value; + const locale = getters.getLocale(); + const tooltipValue = formatValue(value, { format: data.cell?.format, locale }); + return { + cellData: { + border: data.border, + style: data.cell && data.cell.style, + format: data.cell && data.cell.format, + content: value.toString(), + }, + tooltip: value ? { props: { content: tooltipValue } } : undefined, + }; + }, + }) .add("COPY_MODIFIER", { apply: (rule: CopyModifier, data: AutofillData, getters: Getters) => { const content = data.cell?.content || ""; diff --git a/src/registries/autofill_rules.ts b/src/registries/autofill_rules.ts index f936f00877..b757107830 100644 --- a/src/registries/autofill_rules.ts +++ b/src/registries/autofill_rules.ts @@ -1,4 +1,11 @@ -import { isDateTimeFormat } from "../helpers"; +import { toJsDate } from "../functions/helpers"; +import { + DateTime, + getTimeDifferenceInWholeDays, + getTimeDifferenceInWholeMonths, + getTimeDifferenceInWholeYears, + isDateTimeFormat, +} from "../helpers"; import { evaluateLiteral } from "../helpers/cells"; import { AutofillModifier, Cell, CellValueType, DEFAULT_LOCALE } from "../types/index"; import { EvaluatedCell, LiteralCell } from "./../types/cells"; @@ -17,6 +24,12 @@ export interface AutofillRule { sequence: number; } +export interface CalendarDateInterval { + years: number; + months: number; + days: number; +} + export const autofillRulesRegistry = new Registry(); const numberPostfixRegExp = /(\d+)$/; @@ -39,7 +52,9 @@ function getGroup( found = true; } const cellValue = - x === undefined || x.isFormula ? undefined : evaluateLiteral(x, { locale: DEFAULT_LOCALE }); + x === undefined || x.isFormula + ? undefined + : evaluateLiteral(x, { locale: DEFAULT_LOCALE, format: x.format }); if (cellValue && filter(cellValue)) { group.push(cellValue); } else { @@ -77,6 +92,86 @@ function calculateIncrementBasedOnGroup(group: number[]) { return increment; } +/** + * Iterates on a list of date intervals. + * if every interval is the same, return the interval + * Otherwise return undefined + * + */ +function getEqualInterval(intervals: CalendarDateInterval[]): CalendarDateInterval | undefined { + if (intervals.length < 2) { + return intervals[0] || { years: 0, months: 0, days: 0 }; + } + + const equal = intervals.every( + (interval) => + interval.years === intervals[0].years && + interval.months === intervals[0].months && + interval.days === intervals[0].days + ); + return equal ? intervals[0] : undefined; +} + +/** + * Based on a group of dates, calculate the increment that should be applied + * to the next date. + * + * This will compute the date difference in calendar terms (years, months, days) + * In order to make abstraction of leap years and months with different number of days. + * + * In case the dates are not equidistant in calendar terms, no rule can be extrapolated + * In case of equidistant dates, we either have in that order: + * - exact date interval (e.g. +n year OR +n month OR +n day) in which case we increment by the same interval + * - exact day interval (e.g. +n days) in which case we increment by the same day interval + * - equidistant dates but not the same interval, in which case we return increment of the same interval + * + * */ +function calculateDateIncrementBasedOnGroup(group: number[]) { + if (group.length < 2) { + return 1; + } + + const jsDates = group.map((date) => toJsDate(date, DEFAULT_LOCALE)); + + const datesIntervals = getDateIntervals(jsDates); + + const datesEquidistantInterval = getEqualInterval(datesIntervals); + if (datesEquidistantInterval === undefined) { + // dates are not equidistant in terms of years, months or days, thus no rule can be extrapolated + return undefined; + } + + // The dates are apart by an exact interval of years, months or days + // but not a combination of them + const exactDateInterval = + Object.values(datesEquidistantInterval).filter((value) => value !== 0).length === 1; + const isSameDay = Object.values(datesEquidistantInterval).every((el) => el === 0); // handles time values (strict decimals) + + if (!exactDateInterval || isSameDay) { + const timeIntervals = jsDates + .map((date, index) => { + if (index === 0) { + return 0; + } + const previous = jsDates[index - 1]; + const days = Math.floor(date.getTime()) - Math.floor(previous.getTime()); + return days; + }) + .slice(1); + const equidistantDates = timeIntervals.every((interval) => interval === timeIntervals[0]); + + if (equidistantDates) { + return group.length * (group[1] - group[0]); + } + } + + return { + years: datesEquidistantInterval.years * group.length, + months: datesEquidistantInterval.months * group.length, + days: datesEquidistantInterval.days * group.length, + }; +} + autofillRulesRegistry .add("simple_value_copy", { condition: (cell: Cell, cells: (Cell | undefined)[]) => { @@ -134,6 +229,47 @@ autofillRulesRegistry }, sequence: 30, }) + .add("increment_dates", { + condition: (cell: Cell, cells: (Cell | undefined)[]) => { + return ( + !cell.isFormula && + evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.number && + !!cell.format && + isDateTimeFormat(cell.format) + ); + }, + generateRule: (cell: LiteralCell, cells: (Cell | undefined)[]) => { + const group = getGroup( + cell, + cells, + (evaluatedCell) => + evaluatedCell.type === CellValueType.number && + !!evaluatedCell.format && + isDateTimeFormat(evaluatedCell.format) + ).map((cell) => Number(cell.value)); + const increment = calculateDateIncrementBasedOnGroup(group); + if (increment === undefined) { + return { type: "COPY_MODIFIER" }; + } + /** requires to detect the current date (requires to be an integer value with the right format) + * detect if year or if month or if day then extrapolate increment required (+1 month, +1 year + 1 day) + */ + const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE }); + if (typeof increment === "object") { + return { + type: "DATE_INCREMENT_MODIFIER", + increment, + current: evaluation.type === CellValueType.number ? evaluation.value : 0, + }; + } + return { + type: "INCREMENT_MODIFIER", + increment, + current: evaluation.type === CellValueType.number ? evaluation.value : 0, + }; + }, + sequence: 25, + }) .add("increment_number", { condition: (cell: Cell) => !cell.isFormula && @@ -142,7 +278,9 @@ autofillRulesRegistry const group = getGroup( cell, cells, - (evaluatedCell) => evaluatedCell.type === CellValueType.number + (evaluatedCell) => + evaluatedCell.type === CellValueType.number && + !isDateTimeFormat(evaluatedCell.format || "") ).map((cell) => Number(cell.value)); const increment = calculateIncrementBasedOnGroup(group); const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE }); @@ -154,3 +292,38 @@ autofillRulesRegistry }, sequence: 40, }); + +/** + * Returns the date intervals between consecutive dates of an array + * in the format of { years: number, months: number, days: number } + * + * The split is necessary to make abstraction of leap years and + * months with different number of days. + * + * @param dates + */ +function getDateIntervals(dates: DateTime[]): CalendarDateInterval[] { + if (dates.length < 2) { + return [{ years: 0, months: 0, days: 0 }]; + } + + const res = dates.map((date, index) => { + if (index === 0) { + return { years: 0, months: 0, days: 0 }; + } + const previous = DateTime.fromTimestamp(dates[index - 1].getTime()); + const years = getTimeDifferenceInWholeYears(previous, date); + const months = getTimeDifferenceInWholeMonths(previous, date) % 12; + previous.setFullYear(previous.getFullYear() + years); + previous.setMonth(previous.getMonth() + months); + + const days = getTimeDifferenceInWholeDays(previous, date); + + return { + years, + months, + days, + }; + }); + return res.slice(1); +} diff --git a/src/types/autofill.ts b/src/types/autofill.ts index c0123ad121..ab95077775 100644 --- a/src/types/autofill.ts +++ b/src/types/autofill.ts @@ -26,6 +26,16 @@ export interface AlphanumericIncrementModifier { numberPostfixLength: number; // the length of the number post fix string, e.g. "0001" is four but "1" is one } +export interface DateIncrementModifier { + type: "DATE_INCREMENT_MODIFIER"; + current: number; + increment: { + years: number; + months: number; + days: number; + }; +} + export interface CopyModifier { type: "COPY_MODIFIER"; } @@ -40,7 +50,8 @@ export type AutofillModifier = | IncrementModifier | AlphanumericIncrementModifier | CopyModifier - | FormulaModifier; + | FormulaModifier + | DateIncrementModifier; export interface Tooltip { props: any; diff --git a/tests/autofill/autofill_plugin.test.ts b/tests/autofill/autofill_plugin.test.ts index 1459a9c3f2..375dbc03fd 100644 --- a/tests/autofill/autofill_plugin.test.ts +++ b/tests/autofill/autofill_plugin.test.ts @@ -218,14 +218,150 @@ describe("Autofill", () => { expect(getCellContent(model, "A6")).toBe("6"); }); - test("Autofill dates", () => { - setCellContent(model, "A1", "3/3/2003"); - setCellContent(model, "A2", "3/4/2003"); - autofill("A1:A2", "A6"); - expect(getCellText(model, "A3")).toBe("3/5/2003"); - expect(getCellText(model, "A4")).toBe("3/6/2003"); - expect(getCellText(model, "A5")).toBe("3/7/2003"); - expect(getCellText(model, "A6")).toBe("3/8/2003"); + describe("Autofill dates", () => { + test("consecutive dates", () => { + setCellContent(model, "A1", "3/28/2003"); + setCellContent(model, "A2", "3/29/2003"); + setCellContent(model, "A3", "3/30/2003"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("3/31/2003"); + expect(getCellText(model, "A5")).toBe("4/1/2003"); + expect(getCellText(model, "A6")).toBe("4/2/2003"); + }); + + test("Descending dates", () => { + setCellContent(model, "A1", "3/4/2003"); + setCellContent(model, "A2", "3/3/2003"); + autofill("A1:A2", "A5"); + expect(getCellText(model, "A3")).toBe("3/2/2003"); + expect(getCellText(model, "A4")).toBe("3/1/2003"); + expect(getCellText(model, "A5")).toBe("2/28/2003"); + }); + + test("Autofill upwards consecutive dates", () => { + setCellContent(model, "A4", "3/31/2003"); + setCellContent(model, "A5", "4/1/2003"); + setCellContent(model, "A6", "4/2/2003"); + autofill("A4:A6", "A1"); + expect(getCellText(model, "A1")).toBe("3/28/2003"); + expect(getCellText(model, "A2")).toBe("3/29/2003"); + expect(getCellText(model, "A3")).toBe("3/30/2003"); + }); + + test("dates with consistent day gap", () => { + setCellContent(model, "A1", "4/21/2003"); + setCellContent(model, "A2", "4/23/2003"); + setCellContent(model, "A3", "4/25/2003"); + autofill("A1:A3", "A7"); + expect(getCellText(model, "A4")).toBe("4/27/2003"); + expect(getCellText(model, "A5")).toBe("4/29/2003"); + expect(getCellText(model, "A6")).toBe("5/1/2003"); + expect(getCellText(model, "A7")).toBe("5/3/2003"); + }); + + test("dates with consistent month gap", () => { + setCellContent(model, "A1", "3/24/2003"); + setCellContent(model, "A2", "5/24/2003"); + setCellContent(model, "A3", "7/24/2003"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("9/24/2003"); + expect(getCellText(model, "A5")).toBe("11/24/2003"); + expect(getCellText(model, "A6")).toBe("1/24/2004"); + }); + + test("dates with consistent year gap", () => { + setCellContent(model, "A1", "3/24/2000"); + setCellContent(model, "A2", "3/24/2003"); + setCellContent(model, "A3", "3/24/2006"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("3/24/2009"); + expect(getCellText(model, "A5")).toBe("3/24/2012"); + expect(getCellText(model, "A6")).toBe("3/24/2015"); + }); + + test("dates 2 year apart with leap year", () => { + setCellContent(model, "A1", "3/24/2000"); + setCellContent(model, "A2", "3/24/2002"); + autofill("A1:A2", "A6"); + expect(getCellText(model, "A3")).toBe("3/24/2004"); + expect(getCellText(model, "A4")).toBe("3/24/2006"); + expect(getCellText(model, "A5")).toBe("3/24/2008"); + expect(getCellText(model, "A6")).toBe("3/24/2010"); + }); + + test("dates with inconsistent day gap", () => { + setCellContent(model, "A1", "4/11/2003"); + setCellContent(model, "A2", "4/12/2003"); + setCellContent(model, "A3", "4/25/2003"); + autofill("A1:A3", "A7"); + expect(getCellText(model, "A4")).toBe("4/11/2003"); + expect(getCellText(model, "A5")).toBe("4/12/2003"); + expect(getCellText(model, "A6")).toBe("4/25/2003"); + expect(getCellText(model, "A7")).toBe("4/11/2003"); + }); + + test("dates with inconsistent month gap", () => { + setCellContent(model, "A1", "4/11/2003"); + setCellContent(model, "A2", "5/11/2003"); + setCellContent(model, "A3", "7/11/2003"); + autofill("A1:A3", "A7"); + expect(getCellText(model, "A4")).toBe("4/11/2003"); + expect(getCellText(model, "A5")).toBe("5/11/2003"); + expect(getCellText(model, "A6")).toBe("7/11/2003"); + expect(getCellText(model, "A7")).toBe("4/11/2003"); + }); + + test("dates with inconsistent year gap", () => { + setCellContent(model, "A1", "4/11/2003"); + setCellContent(model, "A2", "4/11/2005"); + setCellContent(model, "A3", "4/11/2006"); + autofill("A1:A3", "A7"); + expect(getCellText(model, "A4")).toBe("4/11/2003"); + expect(getCellText(model, "A5")).toBe("4/11/2005"); + expect(getCellText(model, "A6")).toBe("4/11/2006"); + expect(getCellText(model, "A7")).toBe("4/11/2003"); + }); + + test("dates with random gaps", () => { + setCellContent(model, "A1", "3/24/2000"); + setCellContent(model, "A2", "3/25/2003"); + setCellContent(model, "A3", "4/24/1997"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("3/24/2000"); + expect(getCellText(model, "A5")).toBe("3/25/2003"); + expect(getCellText(model, "A6")).toBe("4/24/1997"); + }); + + test("dates wich constant year/month gap", () => { + setCellContent(model, "A1", "2/1/2001"); + setCellContent(model, "A2", "3/1/2002"); + setCellContent(model, "A3", "4/1/2003"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("5/1/2004"); + expect(getCellText(model, "A5")).toBe("6/1/2005"); + expect(getCellText(model, "A6")).toBe("7/1/2006"); + }); + + test("dates with constant month/day gap", () => { + setCellContent(model, "A1", "2/1/2001"); + setCellContent(model, "A2", "3/2/2001"); + setCellContent(model, "A3", "4/3/2001"); + autofill("A1:A3", "A6"); + // Note: differs from Excel but consistent with other cases + expect(getCellText(model, "A4")).toBe("5/4/2001"); + expect(getCellText(model, "A5")).toBe("6/5/2001"); + expect(getCellText(model, "A6")).toBe("7/6/2001"); + }); + + test("dates with constant year/day gap", () => { + setCellContent(model, "A1", "1/3/2001"); + setCellContent(model, "A2", "1/5/2002"); + setCellContent(model, "A3", "1/7/2003"); + autofill("A1:A3", "A6"); + expect(getCellText(model, "A4")).toBe("1/9/2004"); + expect(getCellText(model, "A5")).toBe("1/10/2005"); + expect(getCellText(model, "A6")).toBe("1/12/2006"); + }); }); test("Autofill hours", () => { @@ -344,6 +480,28 @@ describe("Autofill", () => { expect(getCellContent(model, "A9")).toBe("test"); }); + test("Autofill dates mixed with numbers", () => { + setCellContent(model, "A1", "1/8/2023"); + setCellContent(model, "A2", "2/8/2023"); + setCellContent(model, "A3", "5"); + autofill("A1:A3", "A7"); + expect(getCellContent(model, "A4")).toBe("3/8/2023"); + expect(getCellContent(model, "A5")).toBe("4/8/2023"); + expect(getCellContent(model, "A6")).toBe("6"); + expect(getCellContent(model, "A7")).toBe("5/8/2023"); + }); + + test("Autofill dates mixed with text", () => { + setCellContent(model, "A1", "1/8/2023"); + setCellContent(model, "A2", "2/8/2023"); + setCellContent(model, "A3", "text"); + autofill("A1:A3", "A7"); + expect(getCellContent(model, "A4")).toBe("3/8/2023"); + expect(getCellContent(model, "A5")).toBe("4/8/2023"); + expect(getCellContent(model, "A6")).toBe("text"); + expect(getCellContent(model, "A7")).toBe("5/8/2023"); + }); + test("Autofill number and text", () => { setCellContent(model, "A1", "1"); setCellContent(model, "A2", "test");