$(() => {
// Always return a two‐digit string
const addLeadingZero = (num) => String(num).padStart(2, "0");
// Build a map from month name → month index (0–11)
const MONTH_NAME_TO_INDEX = {
January: 0,
February: 1,
March: 2,
April: 3,
May: 4,
June: 5,
July: 6,
August: 7,
September: 8,
October: 9,
November: 10,
December: 11,
};
// Given a regex match array ,
// return an object containing:
// • `utcDate` (the Date representing that UTC timestamp),
// • and Date objects for today/yesterday/tomorrow (at local midnight).
const parseTimestamp = ([
,
rawHour,
rawMinute,
rawDay,
rawMonth,
rawYear,
]) => {
const hour = parseInt(rawHour, 10);
const minute = parseInt(rawMinute, 10);
const day = parseInt(rawDay, 10);
const month = MONTH_NAME_TO_INDEX;
const year = parseInt(rawYear, 10);
// Build a Date object interpreted as UTC:
// newDateUTC will represent “year‐month‐day hour:minute UTC.”
const newDateUTC = new Date(Date.UTC(year, month, day, hour, minute));
// “Today,” “Yesterday,” “Tomorrow” in local time, but pinned to midnight:
const today = new Date();
const localMidnight = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate()
);
const yesterday = new Date(localMidnight.getTime() - 24 * 60 * 60 * 1000);
const tomorrow = new Date(localMidnight.getTime() + 24 * 60 * 60 * 1000);
return { utcDate: newDateUTC, today: localMidnight, yesterday, tomorrow };
};
// Singular/Plural helper
const pluralize = (singular, count, pluralForm = null) => {
if (count === 1) return singular;
return pluralForm || `${singular}s`;
};
class CommentsInLocalTime {
constructor() {
// If user has defined window.LocalComments, use that. Otherwise start fresh.
this.LocalComments = window.LocalComments || {};
// Default language strings (can be overridden by window.LocalComments.language)
const defaultLanguage = {
Today: "Today",
Yesterday: "Yesterday",
Tomorrow: "Tomorrow",
last: "last",
this: "this",
Sunday: "Sunday",
Monday: "Monday",
Tuesday: "Tuesday",
Wednesday: "Wednesday",
Thursday: "Thursday",
Friday: "Friday",
Saturday: "Saturday",
January: "January",
February: "February",
March: "March",
April: "April",
May: "May",
June: "June",
July: "July",
August: "August",
September: "September",
October: "October",
November: "November",
December: "December",
ago: "ago",
"from now": "from now",
year: "year",
years: "years",
month: "month",
months: "months",
day: "day",
days: "days",
};
// Merge language defaults if not already present
this.LocalComments.language = Object.assign(
{},
defaultLanguage,
this.LocalComments.language || {}
);
// Default user preferences (only set if not already present)
const defaultSettings = {
dateDifference: true,
dateFormat: "dmy",
dayOfWeek: true,
dropDays: 0,
dropMonths: 0,
timeFirst: true,
twentyFourHours: false,
};
for (const of Object.entries(defaultSettings)) {
if (this.LocalComments === undefined) {
this.LocalComments = val;
}
}
// Cache month‐name and weekday‐name arrays, based on chosen language
this.monthNames = [
this.LocalComments.language.January,
this.LocalComments.language.February,
this.LocalComments.language.March,
this.LocalComments.language.April,
this.LocalComments.language.May,
this.LocalComments.language.June,
this.LocalComments.language.July,
this.LocalComments.language.August,
this.LocalComments.language.September,
this.LocalComments.language.October,
this.LocalComments.language.November,
this.LocalComments.language.December,
];
this.weekdayNames = [
this.LocalComments.language.Sunday,
this.LocalComments.language.Monday,
this.LocalComments.language.Tuesday,
this.LocalComments.language.Wednesday,
this.LocalComments.language.Thursday,
this.LocalComments.language.Friday,
this.LocalComments.language.Saturday,
];
}
// Main entry: find every “HH:MM, D Month YYYY (UTC)” and replace it.
run() {
const namespace = mw.config.get("wgCanonicalNamespace");
if (.includes(namespace)) return;
if (location.href.includes("action=history")) return;
const container = document.querySelector(
".mw-body-content .mw-parser-output"
);
if (container) {
// Regex: 1–2 digits for hour, :, 2 digits for minute, comma, space,
// 1–2 digits day, space, capitalized month, space, 4-digit year, space, “(UTC)”.
const timestampRegex =
/(\d{1,2}):(\d{2}), (\d{1,2}) (+) (\d{4}) \(UTC\)/g;
this.replaceInNode(container, timestampRegex);
}
}
// Recursively traverse text nodes to find and replace matches for `regex`.
replaceInNode(node, regex) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
// Skip code/pre blocks
const parentTag = node.parentNode && node.parentNode.nodeName;
if (parentTag === "CODE" || parentTag === "PRE") return;
const text = node.nodeValue;
regex.lastIndex = 0; // reset
const match = regex.exec(text);
if (match) {
const matchStr = match;
const index = match.index;
const before = text.slice(0, index);
const after = text.slice(index + matchStr.length);
const localTimeString = this.adjustTime(match);
// Create a <span> for the converted local time
const span = document.createElement("span");
span.className = "localcomments";
span.style.fontSize = "95%";
span.title = matchStr;
span.textContent = localTimeString;
// Replace with:
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(span);
if (after) fragment.appendChild(document.createTextNode(after));
node.parentNode.replaceChild(fragment, node);
}
} else {
// Recurse into child nodes
for (const child of Array.from(node.childNodes)) {
this.replaceInNode(child, regex);
}
}
}
// Given a single regex match array (),
// return the formatted “HH:MMam/pm, DATE (UTC+Offset)” or “DATE, HH:MMam/pm (UTC+Offset)”
adjustTime(matchArray) {
const { utcDate, today, yesterday, tomorrow } =
parseTimestamp(matchArray);
if (isNaN(utcDate.getTime())) {
// Invalid date, return original string
return matchArray;
}
// Determine how to print the date portion (“Today”, “Yesterday”, etc. or full date)
const dateText = this.determineDateText(
utcDate,
today,
yesterday,
tomorrow
);
// Determine hour/minute
let hours = utcDate.getHours();
hours = addLeadingZero(hours);
const minutes = addLeadingZero(utcDate.getMinutes());
const timeString = `${hours}:${minutes}`;
// Compute UTC offset (hours, possibly with fraction). E.g. +2, −7.5, etc.
const offsetHours = -utcDate.getTimezoneOffset() / 60;
// toFixed(1) only if decimal part exists
const offsetStr =
offsetHours >= 0
? `+${offsetHours.toFixed(offsetHours % 1 === 0 ? 0 : 1)}`
: `−${Math.abs(offsetHours).toFixed(offsetHours % 1 === 0 ? 0 : 1)}`;
const utcPart = `(UTC${offsetStr})`;
// final ordering: either “time, date (UTC±X)” or “date, time (UTC±X)”
return this.LocalComments.timeFirst
? `${timeString}, ${dateText} ${utcPart}`
: `${dateText}, ${timeString} ${utcPart}`;
}
// Decide between “Today”, “Yesterday”, “Tomorrow”, or a full date string
determineDateText(utcDate, today, yesterday, tomorrow) {
const year = utcDate.getFullYear();
const month = utcDate.getMonth() + 1; // 1–12
const day = utcDate.getDate();
// Helper to format “YYYY→MM→DD” as strings, since today/yesterday/tomorrow are at local midnight
const padMonth = addLeadingZero(month);
const padDay = day; // day will be used as number in comparisons
const compareY = today.getFullYear();
const compareM = addLeadingZero(today.getMonth() + 1);
const compareD = today.getDate();
// Today?
if (year === compareY && padMonth === compareM && padDay === compareD) {
return this.LocalComments.language.Today;
}
// Yesterday?
const yY = yesterday.getFullYear();
const yM = addLeadingZero(yesterday.getMonth() + 1);
const yD = yesterday.getDate();
if (year === yY && padMonth === yM && padDay === yD) {
return this.LocalComments.language.Yesterday;
}
// Tomorrow?
const tY = tomorrow.getFullYear();
const tM = addLeadingZero(tomorrow.getMonth() + 1);
const tD = tomorrow.getDate();
if (year === tY && padMonth === tM && padDay === tD) {
return this.LocalComments.language.Tomorrow;
}
// Otherwise, build full date text + optional “, last Friday (2 days ago)”
return this.createFullDateText(utcDate, day, month, year, today);
}
// Construct “DD Month YYYY” or “Month DD, YYYY” or “YYYY-MM-DD” plus optional “, last Wednesday (X days ago)”
createFullDateText(utcDate, day, month, year, today) {
const lang = this.LocalComments.language;
const monthName = this.monthNames;
let mainDate;
switch (this.LocalComments.dateFormat.toLowerCase()) {
case "mdy":
mainDate = `${monthName} ${day}, ${year}`;
break;
case "iso":
mainDate = `${year}-${addLeadingZero(month)}-${addLeadingZero(day)}`;
break;
default: // 'dmy'
mainDate = `${day} ${monthName} ${year}`;
}
// Add weekday if desired (“, last Tuesday” or “, this Monday”)
let weekdayText = "";
if (this.LocalComments.dayOfWeek) {
const wd = this.weekdayNames;
// Compute how many days difference
const msDelta =
today.getTime() -
new Date(
utcDate.getFullYear(),
utcDate.getMonth(),
utcDate.getDate()
).getTime();
const dayDelta = Math.round(msDelta / (1000 * 60 * 60 * 24));
let prefix = "";
if (this.LocalComments.dateDifference) {
if (dayDelta >= 0 && dayDelta <= 7) {
prefix = `${lang.last} `;
} else if (dayDelta < 0 && Math.abs(dayDelta) <= 7) {
prefix = `${lang.this} `;
}
}
weekdayText = `, ${prefix}${wd}`;
}
// If relative dates (“(2 days ago, 1 month ago)”) are enabled, append them
let relativeSuffix = "";
if (this.LocalComments.dateDifference) {
const suffixText = this.buildRelativeSuffix(utcDate, today);
if (suffixText) {
relativeSuffix = ` (${suffixText})`;
}
}
return `${mainDate}${weekdayText}${relativeSuffix}`;
}
// Build a string like “2 days ago, 1 month ago” (respecting dropDays/dropMonths)
buildRelativeSuffix(utcDate, today) {
const lang = this.LocalComments.language;
const msAgo =
today.getTime() -
new Date(
utcDate.getFullYear(),
utcDate.getMonth(),
utcDate.getDate()
).getTime();
const daysAgo = Math.abs(Math.round(msAgo / (1000 * 60 * 60 * 24)));
// Calculate rough months and years
const totalMonths = Math.floor((daysAgo / 365) * 12);
let years = Math.floor(totalMonths / 12);
let months = totalMonths - years * 12;
let days = daysAgo - Math.floor((totalMonths * 365) / 12);
// Apply dropDays / dropMonths rules
if (daysAgo < this.LocalComments.dropDays) {
months = 0;
years = 0;
} else if (this.LocalComments.dropDays > 0 && totalMonths >= 1) {
days = 0;
}
if (totalMonths < this.LocalComments.dropMonths) {
years = 0;
} else if (this.LocalComments.dropMonths > 0) {
months = 0;
}
// Build parts array
const parts = ;
if (years > 0) {
parts.push(`${years} ${pluralize(lang.year, years, lang.years)}`);
}
if (months > 0) {
parts.push(`${months} ${pluralize(lang.month, months, lang.months)}`);
}
if (days > 0) {
parts.push(`${days} ${pluralize(lang.day, days, lang.days)}`);
}
if (!parts.length) return "";
// Determine “ago” vs “from now”
const suffixWord = msAgo >= 0 ? lang.ago : lang;
return `${parts.join(", ")} ${suffixWord}`;
}
}
// Avoid running more than once
if (!window.commentsInLocalTimeWasRun) {
window.commentsInLocalTimeWasRun = true;
new CommentsInLocalTime().run();
}
});