From 785712f30829ff9ca5f71b77b0260f4c907c5d44 Mon Sep 17 00:00:00 2001 From: J Mor Date: Fri, 31 Jul 2026 13:28:09 -0400 Subject: [PATCH] Added time toggling & updating --- package.json | 2 +- src/REMT.ts | 3 + src/js/components/TimeKeeper.ts | 463 ++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/js/components/TimeKeeper.ts diff --git a/package.json b/package.json index 61ca1c9..3c913fd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mod621", "displayName": "RE621 Mod Tools", "namespace": "mod621.bitwolfy.com", - "version": "6.5.1", + "version": "6.6.0", "description": "Moderation toolkit for e621.net", "author": "bitWolfy", "contributors": [ diff --git a/src/REMT.ts b/src/REMT.ts index fd4e175..eec3ff1 100644 --- a/src/REMT.ts +++ b/src/REMT.ts @@ -23,6 +23,7 @@ import AutoClickPosts from "./js/components/AutoClickPosts"; import FilterOldFeedbacks from "./js/components/FilterOldFeedbacks"; import CiteUser from "./js/components/CiteUser"; import RipStaffNotes from "./js/components/RipStaffNotes"; +import TimeKeeper from "./js/components/TimeKeeper"; export default class REMT { @@ -47,6 +48,7 @@ export default class REMT { FilterOldFeedbacks, CiteUser, RipStaffNotes, + TimeKeeper, ]; public async run(): Promise { @@ -168,4 +170,5 @@ interface ComponentListAnnotated extends Partial { FilterOldFeedbacks?: FilterOldFeedbacks, CiteUser?: CiteUser, RipStaffNotes?: RipStaffNotes, + TimeKeeper?: TimeKeeper, } diff --git a/src/js/components/TimeKeeper.ts b/src/js/components/TimeKeeper.ts new file mode 100644 index 0000000..4bfd8e0 --- /dev/null +++ b/src/js/components/TimeKeeper.ts @@ -0,0 +1,463 @@ +import { html } from "../utilities/HtmlTemplate"; +import Component from "./Component"; + +export default class TimeKeeper extends Component { + + public constructor() { + super({ + waitForDOM: "time", + }); + } + + Settings = { + enabled: true, + addToggle: Timestamp.addToggle, + }; + + protected create(): Promise { + this.initSettingsMenu(); + this.on("settings.addToggle", (ev, d) => { + if (Timestamp.addToggle !== !!d) { + Timestamp.addToggle = !!d; + Timestamp.teardownAll(); + Timestamp.tryInitAll(); + } + }); + this.on("settings.enabled", (ev, d) => { + if (!d) Timestamp.teardownAll(); + else Timestamp.tryInitAll(); + }); + Timestamp.tryInitAll(); + return Promise.resolve(); + } + + private initSettingsMenu() { + this.settingsMenuDialogParameters = { + elements:[ + $(this.enabledElement), + $(`
`), + $(this.simpleSettingsCheckbox("addToggle", undefined)), + $(`
`), + $(this.resetSettingsDialogElement), + $(`
`), + ], + optionsOrTitle: "Time Keeper Settings", + then: (e: FormData) => { + if (this.handleResetSettingsDialogElement(e)) return; + if (!this.handleEnabledElement(e)) return; + this.Settings.addToggle = e.get(`${this.settingsIdPrefix}addToggle`) === "true"; + }}; + } +} +interface FormatSpecification { + format: string; + matcher: RegExp; + get updateIntervalMs(): number; + get lowerBoundMs(): number; + upperBoundMs: number; + nextSmallestFormat?: string; + nextLargestFormat?: string; +} +type FormatInstanceData = { + format: string; + count?: number; +}; +type FormatSpecMap = { + [k: string]: Readonly, + + less_than_x_seconds: Readonly, + half_a_minute: Readonly, + less_than_x_minutes: Readonly, + x_minutes: Readonly, + about_x_hours: Readonly, + x_days: Readonly, + about_x_months: Readonly, + x_months: Readonly, + about_x_years: Readonly, + over_x_years: Readonly, + almost_x_years: Readonly, +}; + +/** + * Automatically updates the displayed time in the same format as the server. + */ +class Timestamp { + public static addToggle = true; + public static tryInitAll () { + Array.from(document.querySelectorAll("time")).forEach((e) => { + if (!this.instances.some(e1 => e1.element === e)) Timestamp.tryInit(e); + }); + } + public static initAll () { + Array.from(document.querySelectorAll("time")).forEach((e) => { + if (!this.instances.some(e1 => e1.element === e)) new Timestamp(e); + }); + } + private static readonly instances: Timestamp[] = []; + public static teardownAll() { + this.instances.forEach(e => e.teardown()); + this.instances.splice(0, this.instances.length); + } + + /** Don't try updating if the interval is more than a day. */ + public static readonly MAX_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Don't schedule an update until the interval is more than 30 seconds. */ + public static readonly MIN_UPDATE_INTERVAL_MS = 30 * 1000; + private formatSpec: FormatSpecification; + private readonly tense: "future" | "past" | "present"; + private count: number; + private get targetTime () { return new Date(this.element.dateTime); } + private timerID?: number; + private readonly options: object = {}; + private pastUpdateInterval = false; + public readonly toggleIcon: HTMLSpanElement | undefined; + public teardown() { + (this.toggleIcon ?? this.element).removeEventListener("click", this.toggleDisplayedStyle); + this.toggleIcon?.remove(); + if (this.isToggleActive) this.toggleDisplayedStyle(); + this.stopUpdating(); + } + + // #region For title attr. toggling + private readonly titleBackup: string; + // #endregion For title attr. toggling + constructor ( + public readonly element: HTMLTimeElement, + ) { + this.titleBackup = element.title; + if (Timestamp.addToggle) { + // if (element.parentElement?.tagName !== "A") { + if (element.parentElement instanceof HTMLAnchorElement) { + const icon = this.toggleIcon = html`ⓘ` as HTMLSpanElement; + element.parentElement?.parentElement?.appendChild(icon); + } + (this.toggleIcon ?? this.element).addEventListener("click", this.toggleDisplayedStyle); + } + const currentText = element.innerText; + for (const range of Timestamp.ranges) { + const m = range.matcher.exec(currentText); + if (!m || !m[0]) continue; + this.formatSpec = range; + this.tense = m.groups!["in"] ? "future" : m.groups!["ago"] ? "past" : "present"; + const count = m.groups!["count"] ?? "0"; + this.count = Number.parseInt(count === "a" ? "1" : count); + // TODO: Options + this.scheduleNextUpdate(); + Timestamp.instances.push(this); + return; + } + console.warn("Should have matched a pattern (text: %s, element: %o)", currentText, element); + throw new Error("Failed to match a pattern"); + } + + public static tryInit(element: HTMLTimeElement) { + try { + return new Timestamp(element); + } catch (error) {console.error(error)} + } + + private readonly toggleDisplayedStyle = () => { + if (!this.isToggleActive) { + this.element.title = this.element.innerText; + this.element.innerText = this.titleBackup; + } else { + this.element.innerText = this.element.title; + this.element.title = this.titleBackup; + } + }; + + private scheduleNextUpdate (doUpdateIfDue = true) { + if (this.pastUpdateInterval) return false; + const updateTime = Timestamp.findUpdateInterval(this.formatSpec, this.targetTime); + if (updateTime !== undefined) { + if (updateTime <= 0) { + if (doUpdateIfDue) this.update(); + return false; + } else { + this.timerID = window.setTimeout(() => this.update(), updateTime); + return true; + } + } else { + this.stopUpdating(); + return false; + } + } + private stopUpdating() { + this.pastUpdateInterval = true; + if (this.timerID) clearTimeout(this.timerID); + this.timerID = undefined; + } + + private static findUpdateInterval (range: FormatSpecification, from: Date, to = new Date()) { + let intervalMs = range.updateIntervalMs; + if (intervalMs > this.MAX_UPDATE_INTERVAL_MS) return undefined; + intervalMs = Math.max(this.MIN_UPDATE_INTERVAL_MS, intervalMs); + return intervalMs - ((Timestamp.findDeltas(from, to).deltaMs - (isFinite(range.lowerBoundMs) ? range.lowerBoundMs : 0)) % intervalMs); + } + + /** Has the humanized text been swapped with the title, or not? */ + private get isToggleActive () { return this.element.title !== this.titleBackup; } + private updateHumanizedTime (str: string) { + if (!this.isToggleActive) this.element.innerText = str; + else this.element.title = str; + } + + private update () { + const newValue = Timestamp.findDeltaFormat(this.targetTime, new Date(), this.options); + if (newValue.format === this.formatSpec.format && (newValue.count ?? 0) === this.count) { + this.scheduleNextUpdate(false); + return; + } + + this.count = newValue.count ?? 0; + this.formatSpec = Timestamp.formats[newValue.format]; + let str = Timestamp.formatToString(newValue); + switch (this.tense) { + case "past": + str = `${str} ago`; + break; + case "future": + str = `in ${str}`; + break; + case "present": + default: + break; + } + + this.updateHumanizedTime(str); + this.scheduleNextUpdate(false); + } + + public static formats: Readonly = Object.freeze({ + less_than_x_seconds: Object.freeze({ + format: "less_than_x_seconds", + matcher: /^(?in )?less than (?a|x|[0-9]+) second(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 1000; }, + lowerBoundMs: -Infinity, + upperBoundMs: (19 * 1000) + 500, + nextSmallestFormat: undefined, + nextLargestFormat: "half_a_minute", + }), + half_a_minute: Object.freeze({ + format: "half_a_minute", + matcher: /^(?in )?(?:(?a|x|[0-9]+) )?half(?: a)? minute(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 20 * 1000; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: (39 * 1000) + 500, + nextSmallestFormat: "less_than_x_seconds", + nextLargestFormat: "less_than_x_minutes", + }), + less_than_x_minutes: Object.freeze({ + format: "less_than_x_minutes", + matcher: /^(?in )?less than (?a|x|[0-9]+) minute(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 20 * 1000; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: (59 * 1000) + 500, + nextSmallestFormat: "half_a_minute", + nextLargestFormat: "x_minutes", + }), + x_minutes: Object.freeze({ + format: "x_minutes", + matcher: /^(?in )?(?a|x|[0-9]+) minute(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 60 * 1000; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: (45 * 60 * 1000), + nextSmallestFormat: "less_than_x_minutes", + nextLargestFormat: "about_x_hours", + }), + about_x_hours: Object.freeze({ + format: "about_x_hours", + matcher: /^(?in )?about (?a|x|[0-9]+) hour(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 60 * 60 * 1000; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: 24 * 60 * 60 * 1000, // 24 hours + nextSmallestFormat: "x_minutes", + nextLargestFormat: "x_days", + }), + x_days: Object.freeze({ + format: "x_days", + matcher: /^(?in )?(?a|x|[0-9]+) day(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return this.lowerBoundMs; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: 30 * 24 * 60 * 60 * 1000, // 30 days + nextSmallestFormat: "about_x_hours", + nextLargestFormat: "about_x_months", + }), + about_x_months: Object.freeze({ + format: "about_x_months", + matcher: /^(?in )?about (?a|x|[0-9]+) month(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return this.lowerBoundMs; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: 60 * 24 * 60 * 60 * 1000, // 60 days + nextSmallestFormat: "x_days", + nextLargestFormat: "x_months", + }), + x_months: Object.freeze({ + format: "x_months", + matcher: /^(?in )?(?a|x|[0-9]+) month(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return 30 * 24 * 60 * 60 * 1000; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: 365 * 24 * 60 * 60 * 1000, // 365 days + nextSmallestFormat: "about_x_months", + nextLargestFormat: "about_x_years", + }), + about_x_years: Object.freeze({ + format: "about_x_years", + matcher: /^(?in )?about (?a|x|[0-9]+) year(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return this.lowerBoundMs; }, + get lowerBoundMs () { return Timestamp.formats[this.nextSmallestFormat].upperBoundMs; }, + upperBoundMs: Infinity, + nextSmallestFormat: "x_months", + nextLargestFormat: "over_x_years", + }), + over_x_years: Object.freeze({ + format: "over_x_years", + matcher: /^(?in )?over (?a|x|[0-9]+) year(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return this.lowerBoundMs; }, + lowerBoundMs: 365 * 24 * 60 * 60 * 1000, // 365 days + upperBoundMs: Infinity, + nextSmallestFormat: "about_x_years", + nextLargestFormat: "almost_x_years", + }), + almost_x_years: Object.freeze({ + format: "almost_x_years", + matcher: /^(?in )?almost (?a|x|[0-9]+) year(?s)?(? ago)?\s*$/, + get updateIntervalMs () { return this.lowerBoundMs; }, + lowerBoundMs: 365 * 24 * 60 * 60 * 1000, // 365 days + upperBoundMs: Infinity, + nextSmallestFormat: "over_x_years", + nextLargestFormat: undefined, + }), + }); + + public static readonly ranges: Readonly = Object.freeze([ + this.formats["less_than_x_seconds"], + this.formats["half_a_minute"], + this.formats["less_than_x_minutes"], + this.formats["x_minutes"], + this.formats["about_x_hours"], + this.formats["x_days"], + this.formats["about_x_months"], + this.formats["x_months"], + this.formats["about_x_years"], + this.formats["over_x_years"], + this.formats["almost_x_years"], + ]); + + public static formatToString (format: FormatInstanceData) { + const temp = format.format + .replace(/_/g, " ") + .replace(/(?<=^| )x(?!= )/, format.count?.toString() ?? ""); + if ((format.count ?? 1) > 1) return temp; + return temp.substring(0, temp.length - 1); + } + + public static timeDeltaInWords (fromTime: Date, options: object) { + return this.findDeltaFormat(fromTime, new Date(), options); + } + + private static findDeltas (fromTime: Date, toTime: Date) { + if (fromTime.valueOf() > toTime.valueOf()) [fromTime, toTime] = [toTime, fromTime]; + const deltaMs = toTime.valueOf() - fromTime.valueOf(), + deltaSecondsRaw = deltaMs / 1000, + deltaMinutes = Math.round(deltaSecondsRaw / 60), + deltaSeconds = Math.round(deltaSecondsRaw); + return { deltaMs, deltaMinutes, deltaSeconds }; + } + + /** 1440 */ private static readonly MINUTES_IN_DAY = 24 * 60; + /** 525600 */ private static readonly MINUTES_IN_YEAR = this.MINUTES_IN_DAY * 365; + /** 131400 */ private static readonly MINUTES_IN_QUARTER_YEAR = this.MINUTES_IN_YEAR / 4; + /** 394200 */ private static readonly MINUTES_IN_THREE_QUARTERS_YEAR = this.MINUTES_IN_QUARTER_YEAR * 3; + + public static findDeltaFormat (fromTime: Date, toTime: Date, options: object): FormatInstanceData { + const { deltaMinutes, deltaSeconds } = this.findDeltas(fromTime, toTime); + return this.findSubYearDeltaFormat(deltaMinutes, deltaSeconds, options) ?? this.findYearDeltaFormat(fromTime, toTime, deltaMinutes); + } + + private static findSecondsDeltaFormat (deltaSeconds: number): FormatInstanceData { + switch (true) { + case deltaSeconds >= 0 && deltaSeconds <= 4: + return { format: "less_than_x_seconds", count: 5 }; + case deltaSeconds >= 5 && deltaSeconds <= 9: + return { format: "less_than_x_seconds", count: 10 }; + case deltaSeconds >= 10 && deltaSeconds <= 19: + return { format: "less_than_x_seconds", count: 20 }; + case deltaSeconds >= 20 && deltaSeconds <= 39: + return { format: "half_a_minute" }; + case deltaSeconds >= 40 && deltaSeconds <= 59: + return { format: "less_than_x_minutes", count: 1 }; + default: + return { format: "x_minutes", count: 1 }; + } + } + + private static findSubYearDeltaFormat (deltaMinutes: number, deltaSeconds: number, options: object) { + switch (true) { + case deltaMinutes >= 0 && deltaMinutes <= 1: + if (!options["include_seconds"]) + return deltaMinutes == 0 + ? { format: "less_than_x_minutes", count: 1 } + : { format: "x_minutes", count: deltaMinutes }; + + return this.findSecondsDeltaFormat(deltaSeconds); + + case deltaMinutes >= 2 && deltaMinutes < 45: + return { format: "x_minutes", count: deltaMinutes }; + case deltaMinutes >= 45 && deltaMinutes < 90: + return { format: "about_x_hours", count: 1 }; + // 90 mins up to 24 hours + case deltaMinutes >= 90 && deltaMinutes < this.MINUTES_IN_DAY: + return { format: "about_x_hours", count: Math.round(deltaMinutes / 60.0) }; + // 24 hours up to 42 hours + case deltaMinutes >= this.MINUTES_IN_DAY && deltaMinutes < 2520: + return { format: "x_days", count: 1 }; + // 42 hours up to 30 days + case deltaMinutes >= 2520 && deltaMinutes < 43200: + return { format: "x_days", count: Math.round(deltaMinutes / this.MINUTES_IN_DAY) }; + // 30 days up to 60 days + case deltaMinutes >= 43200 && deltaMinutes < 86400: + return { format: "about_x_months", count: Math.round(deltaMinutes / 43200.0) }; + // 60 days up to 365 days + case deltaMinutes >= 86400 && deltaMinutes < this.MINUTES_IN_YEAR: + return { format: "x_months", count: Math.round(deltaMinutes / 43200.0) }; + default: + return null; + } + } + + private static findYearDeltaFormat (fromTime: Date, toTime: Date, deltaMinutes = Math.round((toTime.valueOf() - fromTime.valueOf()) / (1000 * 60))): Required { + let from_year = fromTime.getFullYear(); + if (fromTime.getMonth() >= 3) + from_year += 1; + let to_year = toTime.getFullYear(); + if (toTime.getMonth() < 3) + to_year -= 1; + + let leap_years: number; + if (from_year > to_year) + leap_years = 0; + else { + const fYear = from_year - 1; + leap_years = Math.round(((to_year / 4) - (to_year / 100) + (to_year / 400)) - ((fYear / 4) - (fYear / 100) + (fYear / 400))); + } + const minute_offset_for_leap_year = leap_years * this.MINUTES_IN_DAY; + + // Discount the leap year days when calculating year distance. + // e.g. if there are 20 leap year days between 2 dates having the same day + // and month then based on 365 days calculation + // the distance in years will come out to over 80 years when in written + // English it would read better as about 80 years. + const minutes_with_offset = deltaMinutes - minute_offset_for_leap_year; + const remainder = (minutes_with_offset % this.MINUTES_IN_YEAR); + const distance_in_years = (minutes_with_offset / this.MINUTES_IN_YEAR); + if (remainder < this.MINUTES_IN_QUARTER_YEAR) + return { format: "about_x_years", count: distance_in_years }; + else if (remainder < this.MINUTES_IN_THREE_QUARTERS_YEAR) + return { format: "over_x_years", count: distance_in_years }; + else + return { format: "almost_x_years", count: distance_in_years + 1 }; + } +} diff --git a/tsconfig.json b/tsconfig.json index 363720a..4d71cba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "outDir": "./dist/", "noImplicitAny": false, "module": "es6", - "target": "es6", + "target": "es2018", "moduleResolution": "node", // "bundler", // "types": [ // "jquery",