Rule34.xxx User Notes

Add private notes to other members

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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">&times;</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);
      }
    });
  }
})();