Add private notes to other members
// ==UserScript==
// @name Rule34.xxx User Notes
// @namespace 861ddd094884eac5bea7a3b12e074f34
// @version 1.0.4
// @description Add private notes to other members
// @author Anonymous, Claude Fable 5 Low
// @match https://rule34.xxx/index.php?page=comment&s=list*
// @match https://rule34.xxx/index.php?page=account&s=profile*
// @match https://rule34.xxx/index.php?page=post&s=view*
// @icon https://external-content.duckduckgo.com/ip3/rule34.xxx.ico
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @license BSD-0
// ==/UserScript==
(function () {
'use strict';
const LOG = '[Rule34.xxx User Notes] ';
const warn = (...a) => console.warn(LOG, ...a);
// storage
/////////////
// One GM value holds the whole DB:
// { "<lowercase username>": { note: string, color: string|null, updated: "ISO date" } }
function loadDb() {
try {
const raw = GM_getValue('user_notes', null);
if (!raw) return {};
const db = JSON.parse(raw);
return db && typeof db === 'object' ? db : {};
} catch (e) {
warn('notes db read error', e);
return {};
}
}
function saveDb(db) {
try {
GM_setValue('user_notes', JSON.stringify(db));
} catch (e) {
warn('notes db write error', e);
}
}
const keyFor = (username) => username.trim().toLowerCase();
const getEntry = (username) => loadDb()[keyFor(username)] || null;
function setEntry(username, note, color) {
const db = loadDb();
const key = keyFor(username);
if (!note && !color) {
delete db[key];
} else {
db[key] = { note, color: color || null, updated: new Date().toISOString() };
}
saveDb(db);
}
// style
///////////
const PAGE_CSS = `
.r34un-icon {
cursor: pointer;
font-size: 12px;
margin-left: 6px;
text-decoration: none;
user-select: none;
}
.r34un-icon.r34un-ghost {
display: none;
opacity: 0.45;
}
div.author:hover .r34un-ghost,
div.col1:hover .r34un-ghost {
display: inline;
}
.r34un-tooltip {
position: absolute;
z-index: 2147483646;
max-width: 340px;
padding: 6px 9px;
border: 1px solid #3a3c40;
border-radius: 6px;
background: #1f2023;
color: #e6e6e6;
font: 12px/1.4 system-ui, sans-serif;
white-space: pre-wrap;
overflow-wrap: break-word;
pointer-events: none;
}
`;
const STYLE_ELEMENT = document.createElement('style');
STYLE_ELEMENT.textContent = PAGE_CSS;
document.head.appendChild(STYLE_ELEMENT);
// edit modal
////////////////
// A native <dialog> in a Shadow root, mirroring the comments-filter settings
// dialog: the shadow boundary keeps site CSS out, and showModal() provides
// backdrop dimming, focus trapping and Esc-to-close (cancel) for free.
const MODAL_CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
dialog.card {
border: 1px solid #3a3c40; border-radius: 8px; color: #e6e6e6;
background: #1f2023; width: min(480px, 90vw);
padding: 14px 16px; margin: auto;
}
dialog.card::backdrop { background: rgba(0,0,0,.6); }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
h1 { font-size: 15px; margin: 0; }
h1 .uname { color: #f08000; }
.x { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; line-height: 1; }
.x:hover { color: #fff; }
textarea {
width: 100%; min-height: 110px; resize: vertical;
border: 1px solid #3a3c40; border-radius: 6px;
background: #141517; color: #e6e6e6; font-size: 13px; padding: 6px 8px;
}
.colorrow { display: flex; align-items: center; gap: 8px; margin: 10px 0 12px; font-size: 12px; }
.colorrow input[type="color"] { width: 42px; height: 24px; padding: 0; border: 1px solid #3a3c40; background: #141517; }
.hint { color: #888; font-size: 11px; margin: 0 0 10px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn { font-size: 12px; padding: 5px 14px; border-radius: 6px; cursor: pointer;
border: 1px solid #3a3c40; background: #2a2c30; color: #e6e6e6; }
.btn:hover { background: #34373c; }
.btn.primary { background: #3b6ea5; border-color: #3b6ea5; }
.btn.primary:hover { background: #4279b8; }
.btn.danger { background: #8a2f2f; border-color: #8a2f2f; margin-right: auto; }
.btn.danger:hover { background: #a53b3b; }
`;
let modal = null; // { dialog, uname, textarea, colorInput, colorOn }
function buildModal() {
if (modal) return modal;
const host = document.createElement('div');
host.id = 'r34un-modal-host';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = MODAL_CSS;
root.appendChild(style);
const dialog = document.createElement('dialog');
dialog.className = 'card';
dialog.innerHTML = `
<div class="row">
<h1>Note for <span class="uname"></span></h1>
<button class="x" title="Cancel" type="button">×</button>
</div>
<textarea placeholder="Note text…"></textarea>
<div class="colorrow">
<label><input type="checkbox" class="coloron"> Highlight color</label>
<input type="color" value="#f08000">
</div>
<p class="hint">Saving with an empty note and no color deletes the entry. Esc cancels.</p>
<div class="actions">
<button class="btn danger" type="button">Delete</button>
<button class="btn primary" type="button">Save</button>
</div>`;
root.appendChild(dialog);
document.body.appendChild(host);
modal = {
dialog,
uname: dialog.querySelector('.uname'),
textarea: dialog.querySelector('textarea'),
colorInput: dialog.querySelector('input[type="color"]'),
colorOn: dialog.querySelector('.coloron'),
username: null,
};
dialog.querySelector('.x').addEventListener('click', () => dialog.close());
dialog.querySelector('.btn.danger').addEventListener('click', () => {
setEntry(modal.username, '', null);
dialog.close();
refreshAll();
});
modal.colorInput.addEventListener('input', () => { modal.colorOn.checked = true; });
const save = () => {
const note = modal.textarea.value.trim();
const color = modal.colorOn.checked ? modal.colorInput.value : null;
setEntry(modal.username, note, color);
dialog.close();
refreshAll();
};
dialog.querySelector('.btn.primary').addEventListener('click', save);
// Capture-phase document listener so Ctrl+Enter is claimed before other
// userscripts' document-level handlers (the keyboard-shortcuts script
// exempts ctrl+enter from its input guard to do form submission).
document.addEventListener(
'keydown',
(e) => {
if (!dialog.open) return;
if (e.key === 'Enter' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
e.preventDefault();
e.stopImmediatePropagation();
save();
}
},
true
);
return modal;
}
function openEditor(username) {
const m = buildModal();
m.username = username;
m.uname.textContent = username;
const entry = getEntry(username);
m.textarea.value = entry ? entry.note : '';
m.colorOn.checked = !!(entry && entry.color);
if (entry && entry.color) m.colorInput.value = entry.color;
m.dialog.showModal();
m.textarea.focus();
}
// tooltip
//////////
let tooltip = null;
function showTooltip(anchor, text) {
hideTooltip();
tooltip = document.createElement('div');
tooltip.className = 'r34un-tooltip';
tooltip.textContent = text;
document.body.appendChild(tooltip);
const r = anchor.getBoundingClientRect();
tooltip.style.left = `${window.scrollX + r.left}px`;
tooltip.style.top = `${window.scrollY + r.bottom + 4}px`;
}
function hideTooltip() {
if (tooltip) tooltip.remove();
tooltip = null;
}
// note icon
////////////
function makeIcon(username) {
const icon = document.createElement('span');
icon.className = 'r34un-icon';
icon.textContent = '📝';
icon.addEventListener('mouseenter', () => {
const entry = getEntry(username);
if (entry && entry.note) showTooltip(icon, entry.note);
});
icon.addEventListener('mouseleave', hideTooltip);
icon.addEventListener('click', (e) => {
e.preventDefault();
hideTooltip();
openEditor(username);
});
return icon;
}
// comment lists
///////////////////
// Global comments page blocks are div.post[id^="c"] with the author link in
// div.author h6 a[href*="uname="]; post detail page blocks are bare
// #comment-list > div[id^="c"] with the author link directly in .col1. The
// icon goes right after the author link's container, and a chosen color
// becomes an inline left border on the block — inline so it wins over the
// comments-filter script's class-based pcf-today border.
const COMMENT_SELECTOR = 'div.post[id^="c"], #comment-list > div[id^="c"]';
const AUTHOR_LINK_SELECTOR =
'div.author h6 a[href*="uname="], :scope > div.col1 > a[href*="uname="]';
const UNAME_RE = /[?&]uname=([^&#]+)/;
function commentUsername(post) {
const a = post.querySelector(AUTHOR_LINK_SELECTOR);
const m = a && a.getAttribute('href').match(UNAME_RE);
return m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : null;
}
function decorateComment(post) {
const username = commentUsername(post);
if (!username) return;
const entry = getEntry(username);
const oldIcon = post.querySelector('.r34un-icon');
if (oldIcon) oldIcon.remove();
// Icon is always present; without a note it is a dimmed "add note"
// affordance revealed only while the author block is hovered (CSS).
const icon = makeIcon(username);
icon.classList.toggle('r34un-ghost', !(entry && entry.note));
const a = post.querySelector(AUTHOR_LINK_SELECTOR);
const h6 = a.closest('h6');
if (h6) h6.appendChild(icon);
else a.after(icon);
if (entry && entry.color) {
post.style.setProperty('border-left', `3px solid ${entry.color}`, 'important');
post.style.setProperty('border-radius', '4px');
post.style.setProperty('padding-left', '8px');
} else {
post.style.removeProperty('border-left');
post.style.removeProperty('border-radius');
post.style.removeProperty('padding-left');
}
}
const allComments = () => document.querySelectorAll(COMMENT_SELECTOR);
function decorateComments() {
allComments().forEach(decorateComment);
}
// Shift+N while hovering a comment opens the editor for its author. Track the
// hovered comment via mouseover delegation rather than per-node listeners so
// it survives the filter script re-rendering the list.
let hoveredComment = null;
document.addEventListener('mouseover', (e) => {
hoveredComment = e.target.closest ? e.target.closest(COMMENT_SELECTOR) : null;
});
// profile page
//////////////////
function profileUsername() {
const h2 = document.querySelector('#content h2');
return h2 ? h2.textContent.trim() : null;
}
function decorateProfile() {
const username = profileUsername();
const table = document.querySelector('#content table.highlightable');
if (!username || !table) return;
let row = table.querySelector('tr[data-r34un]');
if (!row) {
row = table.insertRow();
row.dataset.r34un = '1';
row.innerHTML = '<td><strong>Note</strong></td><td></td>';
}
const cell = row.cells[1];
cell.textContent = '';
const entry = getEntry(username);
if (entry && entry.note) {
cell.appendChild(makeIcon(username));
cell.appendChild(document.createTextNode(` ${entry.note}`));
} else {
const span = document.createElement('span');
const text1 = document.createTextNode('(');
const anchor = document.createElement('a');
anchor.href = '#';
anchor.textContent = 'add';
anchor.addEventListener('click', (e) => {
e.preventDefault();
openEditor(username);
});
const text2 = document.createTextNode(')');
span.appendChild(text1);
span.appendChild(anchor);
span.appendChild(text2);
cell.appendChild(span);
}
if (entry && entry.color) {
cell.style.setProperty('border-left', `3px solid ${entry.color}`);
cell.style.setProperty('padding-left', '8px');
} else {
cell.style.removeProperty('border-left');
cell.style.removeProperty('padding-left');
}
}
// wiring
////////////
const params = new URLSearchParams(location.search);
const IS_COMMENTS = params.get('page') === 'comment' && params.get('s') === 'list';
const IS_PROFILE = params.get('page') === 'account' && params.get('s') === 'profile';
const IS_POST = params.get('page') === 'post' && params.get('s') === 'view';
function refreshAll() {
if (IS_COMMENTS || IS_POST) decorateComments();
if (IS_PROFILE) decorateProfile();
}
// Shadow DOM retargets events, so document-level e.target is the modal's
// host div rather than its textarea; resolve the real focus target through
// nested shadow roots instead.
function getDeepActiveElement() {
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el;
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'N' || !e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
if (modal && modal.dialog.open) return;
// don't trigger when an input field has focus
const active = getDeepActiveElement();
if (
active &&
(active.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
)
return;
const username = IS_PROFILE
? profileUsername()
: hoveredComment && commentUsername(hoveredComment);
if (!username) return;
e.preventDefault();
openEditor(username);
});
refreshAll();
// The comments-filter script rebuilds the comment list asynchronously;
// re-decorate when comment blocks appear or change. Icon insertions retrigger
// the observer, so skip runs while our own decoration mutates the DOM.
if (IS_COMMENTS) {
const content = document.getElementById('content');
if (content) {
let scheduled = false;
new MutationObserver((muts) => {
if (scheduled) return;
if (!muts.some((m) => [...m.addedNodes].some((n) => n.nodeType === 1))) return;
scheduled = true;
setTimeout(() => {
decorateComments();
setTimeout(() => { scheduled = false; }, 0);
}, 50);
}).observe(content, { childList: true, subtree: true });
}
}
// export/import
///////////////////
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Export user notes (JSON)', () => {
const json = JSON.stringify(loadDb(), null, 2);
const copy = navigator.clipboard
? navigator.clipboard.writeText(json)
: Promise.reject();
copy
.then(() => alert('User notes JSON copied to clipboard.'))
.catch(() => prompt('Copy the user notes JSON:', json));
});
GM_registerMenuCommand('Import user notes (JSON)', () => {
const json = prompt('Paste user notes JSON (merged into existing notes):');
if (!json) return;
try {
const incoming = JSON.parse(json);
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
throw new Error('not an object');
}
const db = loadDb();
let count = 0;
for (const [key, entry] of Object.entries(incoming)) {
if (!entry || typeof entry.note !== 'string') continue;
db[keyFor(key)] = {
note: entry.note,
color: typeof entry.color === 'string' ? entry.color : null,
updated: entry.updated || new Date().toISOString(),
};
count++;
}
saveDb(db);
refreshAll();
alert(`Imported ${count} note(s).`);
} catch (e) {
alert('Import failed: invalid JSON. ' + e.message);
}
});
}
})();