Custom timed updater + deleted marker
// ==UserScript==
// @name ejchan 1
// @namespace local.ejchan.deleted-marker
// @version 1.1.1
// @description Custom timed updater + deleted marker
// @match https://ejchan.net/*/res/*.html*
// @match https://ejchan.site/*/res/*.html*
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const CFG = {
minIntervalSec: 5,
maxIntervalSec: 60,
defaultIntervalSec: 10,
// Network safety.
fetchTimeoutMs: 25000,
// Blocks custom.js Favorites JSON polling, but should not block cross-thread hover.
blockCustomJsJsonPolling: true,
// Native Tinyboard/Vichan updater blocker.
nativeOffWatchdogMs: 1000,
// Page init after custom insert.
postInitDelayMs: 120,
// Expensive site function patch:
// custom.js -> updatePostLimits -> querySelectorAll can lag badly on form click.
patchUpdatePostLimits: true,
// Minimum time between real original updatePostLimits() runs.
// Synchronous click calls are skipped/deferred instead of scanning immediately.
updatePostLimitsMinIntervalMs: 5 * 60 * 1000,
// If requestIdleCallback is unavailable/busy, run deferred update within this timeout.
updatePostLimitsIdleTimeoutMs: 10000,
debug: false
};
const log = (...args) => {
if (CFG.debug) console.log('[ejchan-helper]', ...args);
};
if (unsafeWindow.__ejDeletedMarkerLoaded || window.__ejDeletedMarkerLoaded) return;
unsafeWindow.__ejDeletedMarkerLoaded = true;
window.__ejDeletedMarkerLoaded = true;
forceNativeStorageOff();
injectPageFetchBlocker();
injectPageBridge();
// Userscript-context fetch for our own requests.
// This should avoid the page-context fetch patch.
const scriptFetch = window.fetch.bind(window);
let threadEl = null;
let board = null;
let threadId = null;
let storagePrefix = null;
let intervalKey = null;
let enabledKey = null;
let intervalSec = CFG.defaultIntervalSec;
let enabled = true;
let checkInFlight = false;
let nativeOffWatchdog = null;
let schedulerTimer = null;
let nextDueAt = 0;
let knownPostNos = new Set();
let unreadCount = 0;
let windowFocused = document.hasFocus();
let baseTitle = '';
let titleWriteLock = false;
onReady(() => {
startInitAttempts();
});
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
function startInitAttempts() {
let attempts = 0;
const maxAttempts = 80;
const tryInit = () => {
attempts++;
threadEl = document.querySelector('.thread[id^="thread_"], .thread[data-threadid]');
const threadLinks = document.querySelector('#thread-links');
if (!threadEl || !threadLinks) {
if (attempts < maxAttempts) {
setTimeout(tryInit, 250);
} else {
console.warn('[ejchan-helper] Could not initialize: thread container or thread links not found.');
}
return;
}
init();
};
tryInit();
}
function init() {
board =
unsafeWindow.board_name ||
window.board_name ||
threadEl.dataset.board ||
location.pathname.split('/')[1];
threadId =
unsafeWindow.thread_id ||
window.thread_id ||
threadEl.dataset.threadid ||
(location.pathname.match(/\/res\/(\d+)\.html/) || [])[1];
if (!board || !threadId) {
console.warn('[ejchan-helper] Could not detect board/thread id.');
return;
}
storagePrefix = `ejdm:${location.hostname}:${board}:${threadId}:`;
intervalKey = storagePrefix + 'intervalSec';
enabledKey = storagePrefix + 'enabled';
intervalSec = clampInterval(Number(GM_getValue(intervalKey, CFG.defaultIntervalSec)));
enabled = GM_getValue(enabledKey, true);
baseTitle = stripNativeCounter(document.title);
injectStyle();
disableNativeAutoUpdate();
startNativeOffWatchdog();
buildControls();
hideNativeUpdater();
knownPostNos = new Set(getLocalPosts().map(p => p.no));
setupFocusTracking();
setupTitleManager();
setupMutationObserver();
resetUnreadCounter();
// One normalization pass. This avoids repeatedly replacing unchanged backlink blocks later.
rebuildMentionedBlocksFromLocal();
if (enabled) {
startLoop();
} else {
setStatus('выкл');
}
}
// ------------------------------------------------------------
// Native/custom updater blocking
// ------------------------------------------------------------
function forceNativeStorageOff() {
try {
localStorage.auto_thread_update = 'false';
} catch (e) {}
}
function injectPageFetchBlocker() {
if (!CFG.blockCustomJsJsonPolling) return;
const code = `
(function () {
if (window.__ejdmFetchPatched) return;
window.__ejdmFetchPatched = true;
var originalFetch = window.fetch;
if (!originalFetch) return;
window.__ejdmOriginalFetch = originalFetch;
window.fetch = function ejdmPatchedFetch(input, init) {
var rawUrl = '';
try {
rawUrl = String(input && input.url ? input.url : input || '');
} catch (e) {}
var stack = '';
try {
stack = new Error().stack || '';
} catch (e) {}
var isThreadJson = /\\/[^\\/]+\\/res\\/\\d+\\.json(?:[?#].*)?$/.test(rawUrl);
/*
Do not block every custom.js JSON request.
Cross-thread hover previews may also fetch thread JSON.
Only block Favorites updater paths from custom.js:
checkForNewPosts()
fetchNewPosts()
*/
var fromFavoritesFetchNewPosts =
/fetchNewPosts/.test(stack) ||
/checkForNewPosts/.test(stack);
if (isThreadJson && fromFavoritesFetchNewPosts) {
if (window.__ejdmDebug) {
console.log('[ejdm page blocker] blocked Favorites updater fetch:', rawUrl, stack);
}
return Promise.resolve(new Response(
JSON.stringify({ posts: [] }),
{
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-EJDM-Blocked': 'favorites-fetchNewPosts'
}
}
));
}
return originalFetch.apply(this, arguments);
};
})();
`;
try {
const s = document.createElement('script');
s.textContent = code;
(document.documentElement || document.head || document).appendChild(s);
s.remove();
} catch (err) {
console.warn('[ejchan-helper] failed to inject page fetch blocker:', err);
}
}
function injectPageBridge() {
const bridgeOptions = {
patchUpdatePostLimits: CFG.patchUpdatePostLimits,
updatePostLimitsMinIntervalMs: CFG.updatePostLimitsMinIntervalMs,
updatePostLimitsIdleTimeoutMs: CFG.updatePostLimitsIdleTimeoutMs,
debug: CFG.debug
};
const code = `
(function () {
var OPTS = ${JSON.stringify(bridgeOptions)};
if (!window.__ejdmPageBridgeInstalled) {
window.__ejdmPageBridgeInstalled = true;
window.__ejdmPageInitPosts = function (postIdsJson) {
var postIds = [];
try {
postIds = JSON.parse(postIdsJson || '[]');
} catch (e) {
postIds = [];
}
/*
Native auto-reload effectively emits new_post and runs initPosts().
We do this in page context for Firefox compatibility.
Keep it minimal: trigger new_post for inserted posts, then initPosts once.
*/
try {
if (window.jQuery && Array.isArray(postIds) && postIds.length) {
postIds.forEach(function (id) {
var node = document.getElementById(id);
if (node) {
window.jQuery(document).trigger('new_post', node);
}
});
}
} catch (e) {
console.warn('[ejdm page bridge] jQuery new_post failed:', e);
}
try {
if (typeof window.initPosts === 'function') {
window.initPosts();
}
} catch (e) {
console.warn('[ejdm page bridge] initPosts failed:', e);
}
setTimeout(function () {
try {
if (typeof window.initPosts === 'function') {
window.initPosts();
}
} catch (e) {}
}, 250);
};
}
function installPostLimitsPatch() {
if (!OPTS.patchUpdatePostLimits) return true;
if (window.__ejdmPostLimitsPatched) return true;
if (typeof window.updatePostLimits !== 'function') return false;
var original = window.updatePostLimits;
window.__oldUpdatePostLimits = original;
window.__ejdmPostLimitsPatched = true;
var stats = window.__ejdmPostLimitsStats = {
calls: 0,
skippedSync: 0,
realRuns: 0,
errors: 0,
pending: false,
lastRun: 0,
lastResult: undefined
};
function runOriginal(ctx, args, reason) {
stats.pending = false;
var now = Date.now();
stats.lastRun = now;
stats.realRuns++;
try {
if (OPTS.debug) {
console.log('[ejdm] running original updatePostLimits:', reason);
}
stats.lastResult = original.apply(ctx || window, args || []);
return stats.lastResult;
} catch (e) {
stats.errors++;
console.warn('[ejdm] original updatePostLimits failed:', e);
return stats.lastResult;
}
}
function scheduleIdleRun() {
if (stats.pending) return;
var now = Date.now();
if (now - stats.lastRun < OPTS.updatePostLimitsMinIntervalMs) return;
stats.pending = true;
var runner = function () {
var now2 = Date.now();
if (now2 - stats.lastRun < OPTS.updatePostLimitsMinIntervalMs) {
stats.pending = false;
return;
}
runOriginal(window, [], 'idle');
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(runner, {
timeout: OPTS.updatePostLimitsIdleTimeoutMs
});
} else {
setTimeout(runner, OPTS.updatePostLimitsIdleTimeoutMs);
}
}
window.__ejdmRunUpdatePostLimitsNow = function () {
return runOriginal(window, [], 'forced');
};
window.updatePostLimits = function ejdmThrottledUpdatePostLimits() {
stats.calls++;
/*
Critical behavior:
Do NOT run expensive querySelectorAll synchronously during click/focus.
The original function is deferred/throttled instead.
*/
stats.skippedSync++;
scheduleIdleRun();
return stats.lastResult;
};
return true;
}
var tries = 0;
function retryPatch() {
if (installPostLimitsPatch()) return;
tries++;
if (tries < 120) {
setTimeout(retryPatch, 250);
}
}
retryPatch();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', retryPatch, { once: true });
} else {
retryPatch();
}
})();
`;
try {
const s = document.createElement('script');
s.textContent = code;
(document.documentElement || document.head || document).appendChild(s);
s.remove();
} catch (err) {
console.warn('[ejchan-helper] failed to inject page bridge:', err);
}
}
function disableNativeAutoUpdate() {
forceNativeStorageOff();
const optBox = document.querySelector('#auto-thread-update > input');
if (optBox && optBox.checked) {
optBox.checked = false;
}
const box = document.querySelector('#auto_update_status');
if (box) {
// Clicking is important if native updater already started,
// because its internal stop_auto_update() lives inside closure.
if (box.checked) {
log('disabling native auto-update');
box.click();
}
box.checked = false;
box.removeAttribute('checked');
}
const secs = document.querySelector('#update_secs');
if (secs) secs.textContent = '';
}
function startNativeOffWatchdog() {
if (nativeOffWatchdog) return;
nativeOffWatchdog = setInterval(() => {
const box = document.querySelector('#auto_update_status');
const nativeEnabled =
box?.checked ||
localStorage.auto_thread_update === 'true';
if (nativeEnabled) {
disableNativeAutoUpdate();
hideNativeUpdater();
}
}, CFG.nativeOffWatchdogMs);
}
function hideNativeUpdater() {
const updater = document.querySelector('#updater');
if (updater) updater.style.display = 'none';
}
// ------------------------------------------------------------
// UI
// ------------------------------------------------------------
function clampInterval(n) {
if (!Number.isFinite(n)) n = CFG.defaultIntervalSec;
n = Math.round(n);
return Math.max(CFG.minIntervalSec, Math.min(CFG.maxIntervalSec, n));
}
function injectStyle() {
const probeLink =
document.querySelector('#thread-links a') ||
document.querySelector('.post a') ||
document.querySelector('a');
const probeText =
document.querySelector('#thread-links') ||
document.querySelector('.post.reply') ||
document.body;
const probePost =
document.querySelector('.post.reply') ||
document.querySelector('.post.op') ||
document.body;
const linkStyle = probeLink ? getComputedStyle(probeLink) : null;
const textStyle = probeText ? getComputedStyle(probeText) : null;
const postStyle = probePost ? getComputedStyle(probePost) : null;
const uiColor =
linkStyle?.color ||
textStyle?.color ||
'currentColor';
const textColor =
textStyle?.color ||
'currentColor';
const postBg =
postStyle?.backgroundColor ||
'transparent';
const postBorder =
postStyle?.borderTopColor ||
uiColor;
const css = `
:root {
--ejdm-ui-color: ${uiColor};
--ejdm-text-color: ${textColor};
--ejdm-post-bg: ${postBg};
--ejdm-post-border: ${postBorder};
--ejdm-delete-bg: #b00020;
--ejdm-delete-fg: #ffffff;
}
#updater {
display: none !important;
}
#ejdm-controls {
margin-left: 8px;
white-space: nowrap;
color: var(--ejdm-ui-color) !important;
font: inherit;
}
#ejdm-controls,
#ejdm-controls label,
#ejdm-controls span,
#ejdm-controls input,
#ejdm-check-now {
color: var(--ejdm-ui-color) !important;
font: inherit;
}
#ejdm-controls input[type="number"] {
width: 3.5em;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
color: var(--ejdm-ui-color) !important;
border: 1px solid var(--ejdm-ui-color);
border-radius: 3px;
padding: 1px 3px;
box-shadow: none !important;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
#ejdm-controls input[type="number"]::-webkit-outer-spin-button,
#ejdm-controls input[type="number"]::-webkit-inner-spin-button {
opacity: 0.7;
}
#ejdm-controls input[type="number"]:focus {
outline: 1px solid var(--ejdm-ui-color);
outline-offset: 1px;
}
#ejdm-enabled {
accent-color: var(--ejdm-ui-color);
}
#ejdm-check-now {
cursor: pointer;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
color: var(--ejdm-ui-color) !important;
border: 1px solid var(--ejdm-ui-color);
border-radius: 3px;
padding: 1px 5px;
box-shadow: none !important;
appearance: none;
-webkit-appearance: none;
}
#ejdm-check-now:hover {
filter: brightness(1.2);
}
#ejdm-check-now:active {
filter: brightness(0.85);
}
#ejdm-status {
margin-left: 4px;
opacity: 0.85;
color: var(--ejdm-ui-color) !important;
}
.ejdm-deleted-post {
opacity: 0.82;
outline: 1px dashed var(--ejdm-delete-bg);
outline-offset: 2px;
}
.ejdm-deleted-tag {
display: inline-block;
margin-right: 6px;
padding: 1px 5px;
border-radius: 3px;
background: var(--ejdm-delete-bg);
color: var(--ejdm-delete-fg);
font-weight: bold;
font-size: 12px;
line-height: 1.4;
}
`;
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function buildControls() {
if (document.querySelector('#ejdm-controls')) return;
const threadLinks = document.querySelector('#thread-links');
const updater = document.querySelector('#updater');
if (!threadLinks && !updater) return;
const controls = document.createElement('span');
controls.id = 'ejdm-controls';
controls.innerHTML = `
<label title="Проверять новые и удалённые посты">
<input id="ejdm-enabled" type="checkbox">
Проверка
</label>
<input id="ejdm-interval" type="number" min="${CFG.minIntervalSec}" max="${CFG.maxIntervalSec}" step="1">
сек
<button id="ejdm-check-now" type="button">Проверить</button>
<span id="ejdm-status"></span>
`;
if (updater) {
updater.insertAdjacentElement('afterend', controls);
} else {
threadLinks.appendChild(controls);
}
const enabledBox = controls.querySelector('#ejdm-enabled');
const intervalInput = controls.querySelector('#ejdm-interval');
const checkBtn = controls.querySelector('#ejdm-check-now');
enabledBox.checked = enabled;
intervalInput.value = String(intervalSec);
enabledBox.addEventListener('change', () => {
enabled = enabledBox.checked;
GM_setValue(enabledKey, enabled);
if (enabled) {
disableNativeAutoUpdate();
hideNativeUpdater();
startLoop();
} else {
stopLoop();
setStatus('выкл');
}
});
intervalInput.addEventListener('change', () => {
intervalSec = clampInterval(Number(intervalInput.value));
intervalInput.value = String(intervalSec);
GM_setValue(intervalKey, intervalSec);
if (enabled) {
startLoop();
}
});
checkBtn.addEventListener('click', async () => {
if (!enabled) return;
stopSchedulerOnly();
await checkOnce('manual');
if (enabled) scheduleNextCheck();
});
setStatus(enabled ? `след. ${intervalSec}с` : 'выкл');
}
function setStatus(text) {
const status = document.querySelector('#ejdm-status');
if (status) status.textContent = text ? `[${text}]` : '';
}
function formatTime24(date = new Date()) {
return new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).format(date);
}
function updateFinalStatus(newCount = 0) {
const time = formatTime24();
const parts = [time];
if (newCount > 0) {
parts.push(`новых ${newCount}`);
} else {
parts.push('ок');
}
setStatus(parts.join(', '));
}
// ------------------------------------------------------------
// Custom recursive scheduler
// ------------------------------------------------------------
function startLoop() {
stopLoop();
disableNativeAutoUpdate();
hideNativeUpdater();
scheduleNextCheck();
}
function stopLoop() {
stopSchedulerOnly();
}
function stopSchedulerOnly() {
if (schedulerTimer) {
clearTimeout(schedulerTimer);
schedulerTimer = null;
}
}
function scheduleNextCheck() {
stopSchedulerOnly();
nextDueAt = Date.now() + intervalSec * 1000;
schedulerTick();
}
function schedulerTick() {
if (!enabled) return;
const leftMs = nextDueAt - Date.now();
const leftSec = Math.max(0, Math.ceil(leftMs / 1000));
if (leftSec > 0) {
setStatus(`след. ${leftSec}с`);
schedulerTimer = setTimeout(schedulerTick, Math.min(1000, leftMs));
return;
}
schedulerTimer = null;
checkOnce('timer').finally(() => {
if (enabled) {
scheduleNextCheck();
}
});
}
// ------------------------------------------------------------
// Custom update
// ------------------------------------------------------------
async function checkOnce(reason = 'unknown') {
if (checkInFlight) return 0;
checkInFlight = true;
try {
disableNativeAutoUpdate();
hideNativeUpdater();
setStatus('проверка...');
log('check:', reason);
const serverSet = await fetchServerPostSet();
const before = compareAndMark(serverSet);
let insertedCount = 0;
let insertedNodes = [];
if (before.missingLocally.length > 0) {
const freshDoc = await fetchFreshThreadDocument();
const inserted = insertMissingPostsFromDocument(freshDoc, before.missingLocally);
insertedCount = inserted.count;
insertedNodes = inserted.nodes;
const backlinksChanged = rebuildMentionedBlocksFromLocal();
if (insertedNodes.length || backlinksChanged) {
callPagePostInit(insertedNodes);
}
}
const freshSetAfterInsert = await fetchServerPostSet();
const after = compareAndMark(freshSetAfterInsert);
if (after.changed) {
const backlinksChanged = rebuildMentionedBlocksFromLocal();
if (backlinksChanged) {
callPagePostInit([]);
}
}
updateFinalStatus(insertedCount);
updateManagedTitle();
return insertedCount;
} catch (err) {
console.error('[ejchan-helper] check failed:', err);
setStatus('ошибка');
return 0;
} finally {
checkInFlight = false;
}
}
async function fetchWithTimeout(url, options = {}, timeoutMs = CFG.fetchTimeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await scriptFetch(url, {
...options,
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
async function fetchServerPostSet() {
try {
return await fetchServerPostSetJson();
} catch (err) {
console.warn('[ejchan-helper] JSON check failed, falling back to HTML:', err);
const doc = await fetchFreshThreadDocument();
return extractPostSetFromDocument(doc);
}
}
async function fetchServerPostSetJson() {
const jsonUrl = location.pathname.replace(/\.html(?:$|\?.*)/, '.json') + '?_=' + Date.now();
const res = await fetchWithTimeout(jsonUrl, {
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'application/json,*/*;q=0.8'
}
});
if (!res.ok) throw new Error(`JSON HTTP ${res.status}`);
const data = await res.json();
if (!data || !Array.isArray(data.posts)) {
throw new Error('Invalid JSON shape: no posts[]');
}
return new Set(data.posts.map(p => Number(p.no)).filter(Boolean));
}
async function fetchFreshThreadDocument() {
const htmlUrl = location.pathname + '?_=' + Date.now();
const res = await fetchWithTimeout(htmlUrl, {
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'text/html,*/*;q=0.8'
}
});
if (!res.ok) throw new Error(`HTML HTTP ${res.status}`);
const html = await res.text();
return new DOMParser().parseFromString(html, 'text/html');
}
function extractPostSetFromDocument(doc) {
const nums = [...doc.querySelectorAll(
'.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
)]
.map(getPostNoFromEl)
.filter(Boolean);
return new Set(nums);
}
function insertMissingPostsFromDocument(doc, missingNos) {
const missingSet = new Set(missingNos.map(Number));
const freshPosts = [...doc.querySelectorAll(
'.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
)];
const postsToInsert = freshPosts
.map(el => ({ no: getPostNoFromEl(el), el }))
.filter(p => p.no && missingSet.has(p.no));
let inserted = 0;
const insertedNodes = [];
for (const { no, el } of postsToInsert) {
if (document.querySelector(`#op_${CSS.escape(String(no))}, #reply_${CSS.escape(String(no))}`)) {
continue;
}
const imported = document.importNode(el, true);
imported.classList.add('new-post');
// Fetched HTML contains server-rendered time text.
// Since we insert manually, convert inserted post times to local time.
localizeInsertedPostTimes(imported);
// Prevent MutationObserver double-count.
knownPostNos.add(no);
threadEl.appendChild(imported);
const br = document.createElement('br');
br.className = 'clear';
threadEl.appendChild(br);
inserted++;
insertedNodes.push(imported);
}
if (inserted > 0 && !isTabActive()) {
unreadCount += inserted;
updateManagedTitle();
}
return {
count: inserted,
nodes: insertedNodes
};
}
// ------------------------------------------------------------
// Page post init / hover init
// ------------------------------------------------------------
function callPagePostInit(insertedNodes = []) {
const insertedIds = insertedNodes
.map(node => node && node.id)
.filter(Boolean);
const idsJson = JSON.stringify(insertedIds);
try {
if (typeof unsafeWindow.__ejdmPageInitPosts === 'function') {
unsafeWindow.__ejdmPageInitPosts(idsJson);
} else {
const s = document.createElement('script');
s.textContent = `
if (window.__ejdmPageInitPosts) {
window.__ejdmPageInitPosts(${JSON.stringify(idsJson)});
} else if (typeof window.initPosts === 'function') {
window.initPosts();
}
`;
(document.documentElement || document.head || document).appendChild(s);
s.remove();
}
} catch (err) {
console.warn('[ejchan-helper] page-context post init failed:', err);
try {
if (typeof unsafeWindow.initPosts === 'function') {
unsafeWindow.initPosts();
}
} catch (err2) {
console.warn('[ejchan-helper] unsafeWindow.initPosts fallback failed:', err2);
}
}
// Keep manually inserted post times local.
for (const post of document.querySelectorAll('.post.new-post')) {
localizeInsertedPostTimes(post);
}
setTimeout(() => {
for (const post of document.querySelectorAll('.post.new-post')) {
localizeInsertedPostTimes(post);
}
}, 300);
}
// ------------------------------------------------------------
// Local time fix for custom-inserted posts
// ------------------------------------------------------------
function localizeInsertedPostTimes(root) {
if (!root) return;
const times = root.matches?.('time[datetime]')
? [root]
: [...root.querySelectorAll?.('time[datetime]') || []];
for (const timeEl of times) {
const dt = timeEl.getAttribute('datetime');
if (!dt) continue;
const date = new Date(dt);
if (Number.isNaN(date.getTime())) continue;
timeEl.textContent = formatEjchanLocalTime(date);
timeEl.dataset.local = 'true';
timeEl.dataset.ejdmLocalized = 'true';
if (!timeEl.title) {
timeEl.title = date.toLocaleString();
}
}
}
function formatEjchanLocalTime(date) {
const pad = n => String(n).padStart(2, '0');
const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const yy = String(date.getFullYear()).slice(-2);
const wd = weekdays[date.getDay()];
const hh = pad(date.getHours());
const mi = pad(date.getMinutes());
const ss = pad(date.getSeconds());
return `${mm}/${dd}/${yy} (${wd}) ${hh}:${mi}:${ss}`;
}
// ------------------------------------------------------------
// Backlink / "Replies:" rebuilding
// ------------------------------------------------------------
function rebuildMentionedBlocksFromLocal() {
const posts = getLocalPosts();
const postByNo = new Map(posts.map(p => [p.no, p.el]));
const backlinks = new Map();
for (const { no: sourceNo, el: sourceEl } of posts) {
const targets = collectQuoteTargets(sourceEl);
for (const targetNo of targets) {
if (!targetNo) continue;
if (targetNo === sourceNo) continue;
// Only create backlink if quoted post exists locally.
// This includes deleted-but-still-visible local posts.
if (!postByNo.has(targetNo)) continue;
if (!backlinks.has(targetNo)) {
backlinks.set(targetNo, new Set());
}
backlinks.get(targetNo).add(sourceNo);
}
}
let changed = false;
for (const { no, el } of posts) {
const oldMentioned = getDirectMentionedBlock(el);
const sourceSet = backlinks.get(no);
if (!sourceSet || sourceSet.size === 0) {
// Do not remove native/site-created mentioned blocks unless they are ours.
if (oldMentioned && oldMentioned.dataset.ejdmSignature) {
oldMentioned.remove();
changed = true;
}
continue;
}
const sourceNos = [...sourceSet].sort((a, b) => a - b);
const newSignature = sourceNos.join(',');
if (oldMentioned) {
const oldSignature =
oldMentioned.dataset.ejdmSignature ||
getMentionedBlockSignature(oldMentioned);
if (oldSignature === newSignature) {
// Do not replace unchanged .mentioned blocks.
oldMentioned.dataset.ejdmSignature = newSignature;
continue;
}
}
const mentioned = document.createElement('div');
mentioned.className = 'mentioned';
mentioned.dataset.ejdmSignature = newSignature;
for (const sourceNo of sourceNos) {
const a = document.createElement('a');
a.className = `mentioned-${sourceNo}`;
a.href = `#${sourceNo}`;
a.textContent = `>>${sourceNo}`;
a.setAttribute('onclick', `highlightReply('${sourceNo}');`);
mentioned.appendChild(a);
}
if (oldMentioned) {
oldMentioned.replaceWith(mentioned);
} else {
el.appendChild(mentioned);
}
changed = true;
}
return changed;
}
function getMentionedBlockSignature(mentionedEl) {
const nums = [...mentionedEl.querySelectorAll('a')]
.map(a => {
const text = a.textContent || '';
const href = a.getAttribute('href') || '';
const cls = a.className || '';
const m =
text.match(/>>\s*(\d+)/) ||
href.match(/#q?(\d+)\b/) ||
cls.match(/mentioned-(\d+)/);
return m ? Number(m[1]) : null;
})
.filter(Boolean)
.sort((a, b) => a - b);
return nums.join(',');
}
function collectQuoteTargets(postEl) {
const result = new Set();
const body = getDirectBodyBlock(postEl);
if (!body) return result;
const links = [...body.querySelectorAll('a')];
for (const a of links) {
const text = a.textContent || '';
const href = a.getAttribute('href') || '';
const onclick = a.getAttribute('onclick') || '';
let m = null;
m = text.match(/>>\s*(\d+)/);
if (m) {
result.add(Number(m[1]));
continue;
}
m = href.match(/#q?(\d+)\b/);
if (m) {
result.add(Number(m[1]));
continue;
}
m = onclick.match(/highlightReply\(['"]?(\d+)['"]?/);
if (m) {
result.add(Number(m[1]));
continue;
}
}
return result;
}
function getDirectBodyBlock(postEl) {
return [...postEl.children].find(el => el.classList?.contains('body')) || null;
}
function getDirectMentionedBlock(postEl) {
return [...postEl.children].find(el => el.classList?.contains('mentioned')) || null;
}
// ------------------------------------------------------------
// Deleted marker
// ------------------------------------------------------------
function compareAndMark(serverSet) {
const localPosts = getLocalPosts();
const localSet = new Set(localPosts.map(p => p.no));
const deletedLocallyPresent = [];
const missingLocally = [];
let changed = false;
for (const { no, el } of localPosts) {
if (!serverSet.has(no)) {
const didMark = markDeleted(el, no);
if (didMark) changed = true;
deletedLocallyPresent.push(no);
} else {
const didUnmark = unmarkDeleted(el);
if (didUnmark) changed = true;
}
}
for (const no of serverSet) {
if (!localSet.has(no)) {
missingLocally.push(no);
}
}
return {
deletedLocallyPresent,
missingLocally,
changed
};
}
function getLocalPosts() {
return [...document.querySelectorAll(
'.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
)]
.map(el => {
const no = getPostNoFromEl(el);
return no ? { no, el } : null;
})
.filter(Boolean);
}
function getPostNoFromEl(el) {
const m = String(el?.id || '').match(/(?:op|reply)_(\d+)/);
return m ? Number(m[1]) : null;
}
function markDeleted(postEl, no) {
if (!postEl || postEl.classList.contains('ejdm-deleted-post')) return false;
postEl.classList.add('ejdm-deleted-post');
postEl.dataset.ejdmDeleted = 'true';
const introLeft =
postEl.querySelector('.intro .post-left') ||
postEl.querySelector('.intro') ||
postEl;
const tag = document.createElement('span');
tag.className = 'ejdm-deleted-tag';
tag.textContent = '(Удалено)';
tag.title = `Пост ${no} отсутствует в свежем JSON/HTML с сервера`;
introLeft.insertAdjacentElement('afterbegin', tag);
return true;
}
function unmarkDeleted(postEl) {
if (!postEl || !postEl.classList.contains('ejdm-deleted-post')) return false;
postEl.classList.remove('ejdm-deleted-post');
delete postEl.dataset.ejDeleted;
postEl.querySelectorAll('.ejdm-deleted-tag').forEach(el => el.remove());
return true;
}
// ------------------------------------------------------------
// Correct unread title counter
// ------------------------------------------------------------
function stripNativeCounter(title) {
return String(title || '').replace(/^\(\d+\)\s+/, '');
}
function isTabActive() {
return !document.hidden && windowFocused && document.hasFocus();
}
function setupFocusTracking() {
window.addEventListener('focus', () => {
windowFocused = true;
if (isTabActive()) resetUnreadCounter();
});
window.addEventListener('blur', () => {
windowFocused = false;
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden && document.hasFocus()) {
windowFocused = true;
resetUnreadCounter();
}
});
document.addEventListener('focusin', () => {
windowFocused = true;
if (isTabActive()) resetUnreadCounter();
});
}
function resetUnreadCounter() {
unreadCount = 0;
updateManagedTitle();
}
function getManagedTitle() {
return unreadCount > 0 ? `(${unreadCount}) ${baseTitle}` : baseTitle;
}
function updateManagedTitle() {
const wanted = getManagedTitle();
if (document.title === wanted) return;
titleWriteLock = true;
document.title = wanted;
setTimeout(() => {
titleWriteLock = false;
}, 0);
}
function setupTitleManager() {
updateManagedTitle();
const titleEl = document.querySelector('title');
if (!titleEl) return;
const obs = new MutationObserver(() => {
if (titleWriteLock) return;
const wanted = getManagedTitle();
if (document.title !== wanted) {
setTimeout(updateManagedTitle, 0);
}
});
obs.observe(titleEl, {
childList: true,
characterData: true,
subtree: true
});
}
function setupMutationObserver() {
const obs = new MutationObserver(mutations => {
const newlySeen = [];
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const posts = [];
if (node.matches?.('.post.op[id^="op_"], .post.reply[id^="reply_"]')) {
posts.push(node);
}
posts.push(...node.querySelectorAll?.('.post.op[id^="op_"], .post.reply[id^="reply_"]') || []);
for (const post of posts) {
const no = getPostNoFromEl(post);
if (!no) continue;
if (!knownPostNos.has(no)) {
knownPostNos.add(no);
newlySeen.push(post);
}
}
}
}
if (newlySeen.length > 0 && !isTabActive()) {
unreadCount += newlySeen.length;
updateManagedTitle();
} else if (newlySeen.length > 0) {
updateManagedTitle();
}
});
obs.observe(threadEl, {
childList: true,
subtree: true
});
}
})();