diff --git a/frontend/package.json b/frontend/package.json index 94471e30..2f580085 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "license": "MIT", "private": true, "dependencies": { + "@cheap-glitch/mi-cron": "^1.0.1", "@formatjs/intl-displaynames": "^5.2.5", "@formatjs/intl-getcanonicallocales": "^1.8.0", "@lit/localize": "^0.11.1", @@ -12,8 +13,6 @@ "@xstate/fsm": "^1.6.2", "axios": "^0.22.0", "color": "^4.0.1", - "cron-parser": "^4.2.1", - "cronstrue": "^1.123.0", "fuse.js": "^6.5.3", "lit": "^2.0.0", "lodash": "^4.17.21", diff --git a/frontend/src/components/crawl-scheduler.ts b/frontend/src/components/crawl-scheduler.ts index 775c7d25..97e6cd30 100644 --- a/frontend/src/components/crawl-scheduler.ts +++ b/frontend/src/components/crawl-scheduler.ts @@ -1,14 +1,25 @@ import { state, property } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; -import cronstrue from "cronstrue"; // TODO localize +import { parseCron } from "@cheap-glitch/mi-cron"; import LiteElement, { html } from "../utils/LiteElement"; -import { getLocaleTimeZone } from "../utils/localization"; +import { + ScheduleInterval, + getScheduleInterval, + getUTCSchedule, + humanizeSchedule, + humanizeNextDate, +} from "../utils/cron"; import type { CrawlTemplate } from "../pages/archive/types"; -const nowHour = new Date().getHours(); -const initialHours = nowHour % 12 || 12; -const initialPeriod = nowHour > 11 ? "PM" : "AM"; +const hours = Array.from({ length: 12 }).map((x, i) => ({ + value: i + 1, + label: `${i + 1}`, +})); +const minutes = Array.from({ length: 60 }).map((x, i) => ({ + value: i, + label: `${i}`.padStart(2, "0"), +})); /** * Usage: @@ -36,49 +47,28 @@ export class CrawlTemplatesScheduler extends LiteElement { cancelable?: boolean = false; @state() - private editedSchedule?: string; + private scheduleInterval: ScheduleInterval | "" = ""; @state() - private isScheduleDisabled?: boolean; + private scheduleTime: { hour: number; minute: number; period: "AM" | "PM" } = + { + hour: new Date().getHours() % 12 || 12, + minute: 0, + period: new Date().getHours() > 11 ? "PM" : "AM", + }; - @state() - private schedulePeriod: "AM" | "PM" = initialPeriod; + private get isScheduleDisabled(): boolean { + return !this.scheduleInterval; + } - private get timeZoneShortName() { - return getLocaleTimeZone(); + firstUpdated() { + this.setInitialValues(); } render() { // TODO consolidate with new - const hours = Array.from({ length: 12 }).map((x, i) => ({ - value: i + 1, - label: `${i + 1}`, - })); - const minutes = Array.from({ length: 60 }).map((x, i) => ({ - value: i, - label: `${i}`.padStart(2, "0"), - })); - const getInitialScheduleInterval = (schedule: string) => { - const [minute, hour, dayofMonth, month, dayOfWeek] = schedule.split(" "); - if (dayofMonth === "*") { - if (dayOfWeek === "*") { - return "daily"; - } - return "weekly"; - } - return "monthly"; - }; - const scheduleIntervalsMap = { - daily: `0 ${nowHour} * * *`, - weekly: `0 ${nowHour} * * ${new Date().getDay()}`, - monthly: `0 ${nowHour} ${new Date().getDate()} * *`, - }; - const initialInterval = this.schedule - ? getInitialScheduleInterval(this.schedule) - : "weekly"; - const nextSchedule = - this.editedSchedule || scheduleIntervalsMap[initialInterval]; + const utcSchedule = this.getUTCSchedule(); return html` @@ -87,23 +77,12 @@ export class CrawlTemplatesScheduler extends LiteElement { { - if (e.target.value) { - this.isScheduleDisabled = false; - this.editedSchedule = `${nextSchedule - .split(" ") - .slice(0, 2) - .join(" ")} ${(scheduleIntervalsMap as any)[e.target.value] - .split(" ") - .slice(2) - .join(" ")}`; - } else { - this.isScheduleDisabled = true; - } + this.scheduleInterval = e.target.value; }} > ${msg("None")} @@ -120,20 +99,16 @@ export class CrawlTemplatesScheduler extends LiteElement { { - const hour = +e.target.value; - const period = this.schedulePeriod; - - this.setScheduleHour({ - hour, - period, - schedule: nextSchedule, - }); + this.scheduleTime = { + ...this.scheduleTime, + hour: +e.target.value, + }; }} > ${hours.map( @@ -145,16 +120,16 @@ export class CrawlTemplatesScheduler extends LiteElement { - (this.editedSchedule = `${e.target.value} ${nextSchedule - .split(" ") - .slice(1) - .join(" ")}`)} + (this.scheduleTime = { + ...this.scheduleTime, + minute: +e.target.value, + })} > ${minutes.map( ({ value, label }) => @@ -165,66 +140,52 @@ export class CrawlTemplatesScheduler extends LiteElement { { - const hour = +e.target - .closest("sl-form") - .querySelector('sl-select[name="scheduleHour"]').value; - const period = "AM"; - - this.schedulePeriod = period; - this.setScheduleHour({ - hour, - period, - schedule: nextSchedule, - }); - }} + @click=${() => + (this.scheduleTime = { + ...this.scheduleTime, + period: "AM", + })} >${msg("AM", { desc: "Time AM/PM" })} { - const hour = +e.target - .closest("sl-form") - .querySelector('sl-select[name="scheduleHour"]').value; - const period = "PM"; - - this.schedulePeriod = period; - this.setScheduleHour({ - hour, - period, - schedule: nextSchedule, - }); - }} + @click=${() => + (this.scheduleTime = { + ...this.scheduleTime, + period: "PM", + })} >${msg("PM", { desc: "Time AM/PM" })} -
+
${this.isScheduleDisabled - ? msg(html`Crawls will not repeat.`) - : msg( - html`New schedule will be:
- ${cronstrue.toString(nextSchedule, { - verbose: true, - })} - (in ${this.timeZoneShortName} time zone)` - )} + ? html`${msg("Crawls will not repeat.")}` + : html` +

${msg(str`Schedule: ${humanizeSchedule(utcSchedule)}.`)}

+

+ ${msg( + str`Next scheduled run: ${humanizeNextDate(utcSchedule)}.` + )} +

+ `}
@@ -256,34 +217,6 @@ export class CrawlTemplatesScheduler extends LiteElement { this.dispatchEvent(new CustomEvent("submit", event)); } - /** - * Set correct local hour in schedule in 24-hr format - **/ - private setScheduleHour({ - hour, - period, - schedule, - }: { - hour: number; - period: "AM" | "PM"; - schedule: string; - }) { - // Convert 12-hr to 24-hr time - let periodOffset = 0; - - if (hour === 12) { - if (period === "AM") { - periodOffset = -12; - } - } else if (period === "PM") { - periodOffset = 12; - } - - this.editedSchedule = `${schedule.split(" ")[0]} ${ - hour + periodOffset - } ${schedule.split(" ").slice(2).join(" ")}`; - } - /** * Stop propgation of sl-select events. * Prevents bug where sl-dialog closes when dropdown closes @@ -292,6 +225,29 @@ export class CrawlTemplatesScheduler extends LiteElement { private stopProp(e: CustomEvent) { e.stopPropagation(); } + + private setInitialValues() { + if (this.schedule) { + const nextDate = parseCron.nextDate(this.schedule)!; + const hours = nextDate.getHours(); + + this.scheduleTime = { + hour: hours % 12, + minute: nextDate.getMinutes(), + period: hours > 11 ? "PM" : "AM", + }; + this.scheduleInterval = getScheduleInterval(this.schedule); + } + } + + private getUTCSchedule(): string { + if (!this.scheduleInterval) return ""; + + return getUTCSchedule({ + interval: this.scheduleInterval, + ...this.scheduleTime, + }); + } } customElements.define("btrix-crawl-scheduler", CrawlTemplatesScheduler); diff --git a/frontend/src/pages/archive/crawl-templates-detail.ts b/frontend/src/pages/archive/crawl-templates-detail.ts index 8cab80f1..08c57260 100644 --- a/frontend/src/pages/archive/crawl-templates-detail.ts +++ b/frontend/src/pages/archive/crawl-templates-detail.ts @@ -2,14 +2,13 @@ import type { HTMLTemplateResult } from "lit"; import { state, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { msg, localized, str } from "@lit/localize"; -import cronstrue from "cronstrue"; // TODO localize import { parse as yamlToJson, stringify as jsonToYaml } from "yaml"; import type { AuthState } from "../../utils/AuthService"; import LiteElement, { html } from "../../utils/LiteElement"; import type { InitialCrawlTemplate } from "./crawl-templates-new"; import type { CrawlTemplate, CrawlConfig } from "./types"; -import { getUTCSchedule } from "./utils"; +import { getUTCSchedule, humanizeSchedule } from "../../utils/cron"; import "../../components/crawl-scheduler"; const SEED_URLS_MAX = 3; @@ -707,16 +706,7 @@ export class CrawlTemplatesDetail extends LiteElement { ${this.crawlTemplate ? html` ${this.crawlTemplate.schedule - ? // TODO localize - // NOTE human-readable string is in UTC, limitation of library - // currently being used. - // https://github.com/bradymholt/cRonstrue/issues/94 - html`${cronstrue.toString(this.crawlTemplate.schedule, { - verbose: true, - })} - (in UTC time zone)` + ? humanizeSchedule(this.crawlTemplate.schedule) : html`${msg("None")}`} ` : html``} diff --git a/frontend/src/pages/archive/crawl-templates-list.ts b/frontend/src/pages/archive/crawl-templates-list.ts index f754dd43..448ff1b0 100644 --- a/frontend/src/pages/archive/crawl-templates-list.ts +++ b/frontend/src/pages/archive/crawl-templates-list.ts @@ -1,7 +1,7 @@ import type { HTMLTemplateResult } from "lit"; import { state, property } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; -import cronParser from "cron-parser"; +import { parseCron } from "@cheap-glitch/mi-cron"; import debounce from "lodash/fp/debounce"; import flow from "lodash/fp/flow"; import map from "lodash/fp/map"; @@ -13,7 +13,11 @@ import type { AuthState } from "../../utils/AuthService"; import LiteElement, { html } from "../../utils/LiteElement"; import type { InitialCrawlTemplate } from "./crawl-templates-new"; import type { CrawlTemplate } from "./types"; -import { getUTCSchedule } from "./utils"; +import { + getUTCSchedule, + humanizeNextDate, + humanizeSchedule, +} from "../../utils/cron"; import "../../components/crawl-scheduler"; type RunningCrawlsMap = { @@ -359,7 +363,6 @@ export class CrawlTemplatesList extends LiteElement { year="2-digit" hour="numeric" minute="numeric" - time-zone-name="short" > ` @@ -376,27 +379,21 @@ export class CrawlTemplatesList extends LiteElement {
${t.schedule ? html` - + + >${humanizeSchedule(t.schedule, { + length: "short", + })} ` diff --git a/frontend/src/pages/archive/crawl-templates-new.ts b/frontend/src/pages/archive/crawl-templates-new.ts index 8586a913..5cbeaf60 100644 --- a/frontend/src/pages/archive/crawl-templates-new.ts +++ b/frontend/src/pages/archive/crawl-templates-new.ts @@ -1,14 +1,13 @@ import { state, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { msg, localized, str } from "@lit/localize"; -import cronParser from "cron-parser"; import { parse as yamlToJson, stringify as jsonToYaml } from "yaml"; import type { AuthState } from "../../utils/AuthService"; import LiteElement, { html } from "../../utils/LiteElement"; -import { getLocaleTimeZone } from "../../utils/localization"; +import { ScheduleInterval, humanizeNextDate } from "../../utils/cron"; import type { CrawlConfig, Profile } from "./types"; -import { getUTCSchedule } from "./utils"; +import { getUTCSchedule } from "../../utils/cron"; type NewCrawlTemplate = { id?: string; @@ -65,7 +64,7 @@ export class CrawlTemplatesNew extends LiteElement { private isRunNow: boolean = initialValues.runNow; @state() - private scheduleInterval: "" | "daily" | "weekly" | "monthly" = ""; + private scheduleInterval: ScheduleInterval | "" = ""; /** Schedule local time */ @state() @@ -92,35 +91,10 @@ export class CrawlTemplatesNew extends LiteElement { @state() private serverError?: string; - private get timeZone() { - return Intl.DateTimeFormat().resolvedOptions().timeZone; - } - - private get timeZoneShortName() { - return getLocaleTimeZone(); - } - private get formattededNextCrawlDate() { const utcSchedule = this.getUTCSchedule(); - return this.scheduleInterval - ? html`` - : undefined; + return this.scheduleInterval ? humanizeNextDate(utcSchedule) : undefined; } connectedCallback(): void { diff --git a/frontend/src/pages/archive/utils.ts b/frontend/src/pages/archive/utils.ts deleted file mode 100644 index d2c536db..00000000 --- a/frontend/src/pages/archive/utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Get schedule as UTC cron job expression - * https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax - **/ -export function getUTCSchedule({ - interval, - minute, - hour, - period, -}: { - interval: "daily" | "weekly" | "monthly"; - minute: number | string; - hour: number | string; - period: "AM" | "PM"; -}): string { - const localDate = new Date(); - - // Convert 12-hr to 24-hr time - let periodOffset = 0; - - if (hour === 12) { - if (period === "AM") { - periodOffset = -12; - } - } else if (period === "PM") { - periodOffset = 12; - } - - localDate.setHours(+hour + periodOffset); - localDate.setMinutes(+minute); - const dayOfMonth = interval === "monthly" ? localDate.getUTCDate() : "*"; - const dayOfWeek = interval === "weekly" ? localDate.getUTCDay() : "*"; - const month = "*"; - - const schedule = `${localDate.getUTCMinutes()} ${localDate.getUTCHours()} ${dayOfMonth} ${month} ${dayOfWeek}`; - - return schedule; -} diff --git a/frontend/src/utils/cron.ts b/frontend/src/utils/cron.ts new file mode 100644 index 00000000..f7cb92b5 --- /dev/null +++ b/frontend/src/utils/cron.ts @@ -0,0 +1,145 @@ +import { parseCron } from "@cheap-glitch/mi-cron"; +import { msg, str } from "@lit/localize"; + +export type ScheduleInterval = "daily" | "weekly" | "monthly"; + +/** + * Parse interval from cron expression + **/ +export function getScheduleInterval( + schedule: string +): "daily" | "weekly" | "monthly" { + const [minute, hour, dayofMonth, month, dayOfWeek] = schedule.split(" "); + if (dayofMonth === "*") { + if (dayOfWeek === "*") { + return "daily"; + } + return "weekly"; + } + return "monthly"; +} + +/** + * Get human-friendly date from cron expression + * Example: "Every day at 9:30 AM CDT" + **/ +export function humanizeNextDate(schedule: string): string { + const nextDate = parseCron.nextDate(schedule); + + if (!nextDate) return ""; + + return nextDate.toLocaleString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + }); +} + +/** + * Get human-friendly schedule from cron expression + * Example: "Every day at 9:30 AM CDT" + **/ +export function humanizeSchedule( + schedule: string, + options: { length?: "short" } = {} +): string { + const interval = getScheduleInterval(schedule); + const { days } = parseCron(schedule)!; + const nextDate = parseCron.nextDate(schedule)!; + const formattedWeekDay = nextDate.toLocaleString(undefined, { + weekday: "long", + }); + + let intervalMsg: any = ""; + + if (options.length === "short") { + const formattedTime = nextDate.toLocaleString(undefined, { + minute: "numeric", + hour: "numeric", + }); + + switch (interval) { + case "daily": + intervalMsg = msg(str`${formattedTime} every day`); + break; + case "weekly": + intervalMsg = msg(str`Every ${formattedWeekDay}`); + break; + case "monthly": + intervalMsg = msg(str`Day ${days[0]} of every month`); + break; + default: + break; + } + } else { + const formattedTime = nextDate.toLocaleString(undefined, { + minute: "numeric", + hour: "numeric", + timeZoneName: "short", + }); + + switch (interval) { + case "daily": + intervalMsg = msg(str`Every day at ${formattedTime}`); + break; + case "weekly": + intervalMsg = msg( + str`Every ${formattedWeekDay} + at ${formattedTime}` + ); + break; + case "monthly": + intervalMsg = msg( + str`On day ${days[0]} of the month at ${formattedTime}` + ); + break; + default: + break; + } + } + + return intervalMsg; +} + +/** + * Get schedule as UTC cron job expression + * https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + **/ +export function getUTCSchedule({ + interval, + minute, + hour, + period, +}: { + interval: ScheduleInterval; + minute: number | string; + hour: number | string; + period: "AM" | "PM"; +}): string { + const localDate = new Date(); + + // Convert 12-hr to 24-hr time + let periodOffset = 0; + + if (hour === 12) { + if (period === "AM") { + periodOffset = -12; + } + } else if (period === "PM") { + periodOffset = 12; + } + + localDate.setHours(+hour + periodOffset); + localDate.setMinutes(+minute); + const dayOfMonth = interval === "monthly" ? localDate.getUTCDate() : "*"; + const dayOfWeek = interval === "weekly" ? localDate.getUTCDay() : "*"; + const month = "*"; + + const schedule = `${localDate.getUTCMinutes()} ${localDate.getUTCHours()} ${dayOfMonth} ${month} ${dayOfWeek}`; + + return schedule; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5a52a53a..d26b072c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -39,6 +39,11 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@cheap-glitch/mi-cron@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@cheap-glitch/mi-cron/-/mi-cron-1.0.1.tgz#111f4ce746c269aedf74533ac806881763a68f99" + integrity sha512-kxl7vhg+SUgyHRn22qVbR9MfSm5CzdlYZDJTbGemqFFi/Jmno/hdoQIvBIPoqFY9dcPyxzOUNRRFn6x88UQMpw== + "@discoveryjs/json-ext@^0.5.0": version "0.5.5" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.5.tgz#9283c9ce5b289a3c4f61c12757469e59377f81f3" @@ -1774,18 +1779,6 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" -cron-parser@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.2.1.tgz#b43205d05ccd5c93b097dae64f3bd811f5993af3" - integrity sha512-5sJBwDYyCp+0vU5b7POl8zLWfgV5fOHxlc45FWoWdHecGC7MQHCjx0CHivCMRnGFovghKhhyYM+Zm9DcY5qcHg== - dependencies: - luxon "^1.28.0" - -cronstrue@^1.123.0: - version "1.123.0" - resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-1.123.0.tgz#de622dd8ea07981790f488d3f89a25e6e728ac8b" - integrity sha512-hVu9yNYRYr+jj5KET1p7FaBxFwtCHM1ByffP9lZ6yJ6p53u4VEmzH8117v9PUydxWNzc8Eq+sCZEzsKcB3ckiA== - cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -3558,11 +3551,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -luxon@^1.28.0: - version "1.28.0" - resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf" - integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ== - make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"