Display & edit crawl schedule in user local time (#271)

closes #255
This commit is contained in:
sua yoo 2022-06-27 13:01:20 -07:00 committed by GitHub
parent c2aa4e6319
commit d144591dbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 268 additions and 257 deletions

View File

@ -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",

View File

@ -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`
<sl-form @sl-submit=${this.onSubmit}>
@ -87,23 +77,12 @@ export class CrawlTemplatesScheduler extends LiteElement {
<sl-select
name="scheduleInterval"
label=${msg("Recurring crawls")}
value=${initialInterval}
value=${this.scheduleInterval}
hoist
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
@sl-select=${(e: any) => {
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;
}}
>
<sl-menu-item value="">${msg("None")}</sl-menu-item>
@ -120,20 +99,16 @@ export class CrawlTemplatesScheduler extends LiteElement {
<sl-select
class="grow"
name="scheduleHour"
value=${initialHours}
value=${this.scheduleTime.hour}
?disabled=${this.isScheduleDisabled}
hoist
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
@sl-select=${(e: any) => {
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 {
<sl-select
class="grow"
name="scheduleMinute"
value="0"
value=${this.scheduleTime.minute}
?disabled=${this.isScheduleDisabled}
hoist
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
@sl-select=${(e: any) =>
(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 {
<input
name="schedulePeriod"
type="hidden"
value=${this.schedulePeriod}
value=${this.scheduleTime.period}
/>
<sl-button-group>
<sl-button
type=${this.schedulePeriod === "AM" ? "neutral" : "default"}
aria-selected=${this.schedulePeriod === "AM"}
type=${this.scheduleTime.period === "AM"
? "neutral"
: "default"}
aria-selected=${this.scheduleTime.period === "AM"}
?disabled=${this.isScheduleDisabled}
@click=${(e: any) => {
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" })}</sl-button
>
<sl-button
type=${this.schedulePeriod === "PM" ? "neutral" : "default"}
aria-selected=${this.schedulePeriod === "PM"}
type=${this.scheduleTime.period === "PM"
? "neutral"
: "default"}
aria-selected=${this.scheduleTime.period === "PM"}
?disabled=${this.isScheduleDisabled}
@click=${(e: any) => {
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" })}</sl-button
>
</sl-button-group>
</div>
</fieldset>
<div class="mt-5">
<div class="mt-5 bg-neutral-50 rounded p-3 text-sm text-neutral-800">
${this.isScheduleDisabled
? msg(html`<span class="font-medium"
>Crawls will not repeat.</span
>`)
: msg(
html`<span class="font-medium">New schedule will be:</span
><br />
<span class="text-0-600"
>${cronstrue.toString(nextSchedule, {
verbose: true,
})}
(in ${this.timeZoneShortName} time zone)</span
>`
)}
? html`<span class="font-medium"
>${msg("Crawls will not repeat.")}</span
>`
: html`
<p>${msg(str`Schedule: ${humanizeSchedule(utcSchedule)}.`)}</p>
<p>
${msg(
str`Next scheduled run: ${humanizeNextDate(utcSchedule)}.`
)}
</p>
`}
</div>
<div class="mt-5${this.cancelable ? " text-right" : ""}">
@ -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);

View File

@ -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`<span
>${cronstrue.toString(this.crawlTemplate.schedule, {
verbose: true,
})}
(in UTC time zone)</span
>`
? humanizeSchedule(this.crawlTemplate.schedule)
: html`<span class="text-0-400">${msg("None")}</span>`}
`
: html`<sl-skeleton></sl-skeleton>`}

View File

@ -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"
></sl-format-date>
</a>
</sl-tooltip>`
@ -376,27 +379,21 @@ export class CrawlTemplatesList extends LiteElement {
<div>
${t.schedule
? html`
<sl-tooltip content=${msg("Next scheduled crawl")}>
<sl-tooltip
content=${msg(
str`Next scheduled crawl: ${humanizeNextDate(t.schedule)}`
)}
>
<span>
<sl-icon
class="inline-block align-middle mr-1"
name="clock-history"
></sl-icon
><sl-format-date
class="inline-block align-middle text-0-600"
date="${cronParser
.parseExpression(t.schedule, {
utc: true,
})
.next()
.toString()}"
month="2-digit"
day="2-digit"
year="2-digit"
hour="numeric"
minute="numeric"
time-zone-name="short"
></sl-format-date>
><span class="inline-block align-middle text-0-600"
>${humanizeSchedule(t.schedule, {
length: "short",
})}</span
>
</span>
</sl-tooltip>
`

View File

@ -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`<sl-format-date
date="${cronParser
.parseExpression(utcSchedule, {
utc: true,
})
.next()
.toString()}"
weekday="long"
month="long"
day="numeric"
year="numeric"
hour="numeric"
minute="numeric"
time-zone-name="short"
time-zone=${this.timeZone}
></sl-format-date>`
: undefined;
return this.scheduleInterval ? humanizeNextDate(utcSchedule) : undefined;
}
connectedCallback(): void {

View File

@ -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;
}

145
frontend/src/utils/cron.ts Normal file
View File

@ -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;
}

View File

@ -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"