Pixiv Tag Translation/Replacement

Shows translations of tags on Pixiv and prompts for untranslated tags.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Pixiv Tag Translation/Replacement
// @description Shows translations of tags on Pixiv and prompts for untranslated tags.
// @namespace   http://scripts.chris.charabaruk.com/pixiv.net/~tag-translation
// @author      coldacid
// @include     http://www.pixiv.net/
// @include     http://www.pixiv.net/*
// @include     http://pixiv.net/
// @include     http://pixiv.net/*
// @include     https://www.pixiv.net/
// @include     https://www.pixiv.net/*
// @include     https://pixiv.net/
// @include     https://pixiv.net/*
// @version     1.3
// @grant       none
// ==/UserScript==

var TagsCollection;
{
  const USER_DATA_KEY = 'com.charabaruk.chris.pixiv.net.tag-translation';
  let version = 1;
  let settings = null;
  let map = null;

  let loadData = function () {
    var userData = window.localStorage[USER_DATA_KEY];
    if (userData) {
      userData = JSON.parse(userData);
    } else {
      userData = {};
    }

    version = userData.version || 1;

    settings = userData.settings || {
      showOriginalTag: true, // or false, or array containing zero or more of ['label', 'hover']
      promptForTranslations: true // or false
    };

    var tags = userData.tags || {
      "R-18": null,
      "3D": null
    };
    tags[Symbol.iterator] = function* () { for (var tag in this) yield [tag, this[tag]]; };
    map = new Map(tags);

    return [version, settings, map];
  };
  let saveData = function () {
    // so we don't overwrite changes made in other tabs, grab the current data first when saving
    var userData = JSON.parse(window.localStorage[USER_DATA_KEY] || `{"version": "${version}", "settings": {}, "tags": {}}`);

    userData.settings = settings; // use current settings
    for (var [k, v] of map.entries()) { userData.tags[k] = v; } // yes, overwrite existing tags when merging

    window.localStorage[USER_DATA_KEY] = JSON.stringify(userData);
  };

  let updateHandlers = new Map();
  let runHandlers = function (tag, translation) {
    for (var [obj, handler] of updateHandlers.entries()) {
      try {
        handler.call(obj, tag, translation);
      } catch (err) {
        console.error('tag translation update handler threw', err);
      }
    }
  };

  window.addEventListener('storage', evt => {
    if (evt.key !== USER_DATA_KEY) { return; }

    console.info("Another tab has updated tag translations, merging");
    var tags = JSON.parse(evt.newValue || "{tags: null}").tags;
    if (!tags) { return; }

    for(var key of Object.getOwnPropertyNames(tags)) {
      if (!map.has(key) || map.get(key) !== tags[key]) {
        console.info(`"${key}": "${map.get(key)}" => "${tags[key]}"`);
        map.set(key, tags[key]); // take remote version over existing one
        runHandlers(key, tags[key]);
      }
    }
  }, false);

  TagsCollection = function TagsCollection () {
    var [version, settings, map] = loadData();

    Object.defineProperty(this, 'version', {value: version});
    Object.defineProperty(this, 'settings', {value: settings});

    Object.defineProperty(this, 'tagUpdated', {
      get: function () { return updateHandler.has(this) ? updateHandler.get(this) : null; },
      set: function (handler) {
        if (!handler || typeof handler !== 'function') {
          updateHandlers.delete(this);
        } else {
          updateHandlers.set(this, handler);
        }
      }
    });
  };

  TagsCollection.prototype.has = function (tag) { return map.has(tag); };
  TagsCollection.prototype.get = function (tag) { return map.get(tag) || tag; };
  TagsCollection.prototype.set = function (tag, translation) {
    if (translation === undefined) {
      if (tag.entries) {
        for (var [key, value] of tag.entries()) {
          map.set(key, value);
        }
      } else if (tag[Symbol.iterator]) {
        for (var [key, value] of tag) {
          map.set(key, value);
        }
      } else if (tag instanceof Object) {
        for (var key of Object.getOwnPropertyNames(tag)) {
          map.set(key, tag[key]);
        }
      } else {
        throw new Error('missing translation');
      }
    } else {
      map.set(tag, translation);
    }

    saveData();
  };
  TagsCollection.prototype.delete = function (tag) {
    map.delete(tag);
    saveData();
  };

  TagsCollection.prototype.tags = function* () { for (var entry of map.entries()) yield entry; };
  TagsCollection.prototype.translations = function () {
    var reversed = {};
    reversed[Symbol.iterator] = function* () { for (var key in this) yield [key, this[key]]; };

    for (var [key, value] of map.entries()) {
      reversed[value] = reversed[value] || [];
      reversed[value].push(key);
    }

    return reversed;
  };

  TagsCollection.prototype.translatedAs = function (translation) {
    translation = translation || '';

    var tags = [];
    for (var [key, value] of map.entries()) {
      if ((value || '').toLowerCase() === translation.toLowerCase())
        tags.push(key);
    }
    return tags;
  };
}

function GM_main ($) {
  var tags = new TagsCollection();
  window.translatedTags = tags;

  var settings = tags.settings;

  function setTagText(node, tag) {
    if (Array.isArray(node) || node instanceof jQuery) {
      node = node[0];
    }

    var showOriginalTag = settings.showOriginalTag,
        showInLabel = showOriginalTag === true || showOriginalTag.indexOf('label') !== -1,
        showOnHover = showOriginalTag === true || showOriginalTag.indexOf('hover') !== -1;

    var $element = $(node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement),
        originalTag = $element.attr('data-tag-translator-original') || $element.text();

    var tagLabel = showInLabel ? `${tag} (${originalTag})` : tag;

    if (node.nodeType === Node.TEXT_NODE) {
      node.textContent = tagLabel;
    } else {
      $element.text(tagLabel);
    }

    $element.attr('data-tag-translator-current', tag);
    if (showOnHover) {
      $element.attr('title', originalTag);
    }
  }

  var tagSelectors = [
    'li.tag > a:not([class~="portal"]):not([target="_blank"])',
    'div.tag-name',
    'section.favorite-tag > ul.favorite-tags > li > a:not([class~="portal"]):not([target="_blank"])',
    'nav.breadcrumb > span a[href^="/tags.php?tag="] > span[itemprop="title"]',
    'ul.tagCloud > li > a:not([class~="portal"]):not([target="_blank"])',
    'ul.tags > li > a:not([class~="portal"]):not([target="_blank"])',
    'table.ws_table td.td2 > a[href^="personal_tags.php?tag="]',
    'div.bookmark-list-unit ul.tag-cloud > li > span.tag[data-tag]',
    'dl.tag-list a.tag-name:not([class~="portal"]):not([target="_blank"])',
    'ul.tag-list a.tag-name:not([class~="portal"]):not([target="_blank"])',
    'h1.column-title > a.self[href^="/search.php"]'
  ].join(', ');

  var untranslated = new Map();
  // content page regular tags, home page featured tags, home page favorite tags
  $(tagSelectors)
    .contents()
    .filter((i, n) => n.nodeType === Node.TEXT_NODE) // only get the text nodes within the selected elements
    .each((i, n) => {
      var $node = $(n),
          tag = $node.text();

      // save original tag value, add edit translation button
      $(n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement)
        .attr('data-tag-translator-original', tag)
        .append('<span class="tags tag-translator-added" style="position:relative;"><span class="portal retranslate">j</span></span>');

      if (tags.has(tag)) {
        // if we have a translation for the tag, update the text for it
        var tl = tags.get(tag);
        console.debug(`Replacing tag "${tag}" with "${tl}"`);
        setTagText($node, tl);
      } else {
        if (!settings.promptForTranslations) {
          // we aren't going to bother with asking for translations, so nothing more to do
          return;
        } else if (/^[\x20-\x7e]*$/.test(tag)) {
          // tag is entirely ASCII, so skip it and go onto the next node for processing
          console.debug(`"${tag}" only uses ASCII printable characters, skipping`);
          return;
        } else {
          // if we don't have a translation and the tag isn't ASCII text, add to the untranslated list
          console.debug(`No translation available for tag "${tag}", adding to the list`);
          let nodes = untranslated.has(tag) ? untranslated.get(tag) : [];
          nodes.push($node);
          untranslated.set(tag, nodes);
        }
      }
    });

  // prompt for translations
  if (untranslated.size > 0) {
    var taglist = Array.from(untranslated.keys()).join(', '),
        tagcount = untranslated.size;
    if (window.confirm(`There are ${tagcount} untranslated tags. Want to translate?\n\nTags: ${taglist}`)) {
      var translations = new Map(), i = 1;
      for (var [tag, $nodes] of untranslated.entries()) {
        // try getting a translated version anyway, just in case it got translated on another tab
        var translated = window.prompt(
          `Translation for: ${tag}\n\nLeave empty to cancel translating, leave as-is to skip [${i++}/${tagcount}]`,
          tags.get(tag));
        if (!translated) { break; }

        // only save if the translation is different from the original tag
        if (tag !== translated) {
          translations.set(tag, translated);
          $nodes.forEach($n => setTagText($n, translated));
        }
      }
      tags.set(translations);
    }
  }

  // set up tag updating
  tags.tagUpdated = (tag, translation) => {
    if (!translation) { translation = tag; } // sanity: if no translation, at least keep the original tag value

    $(`[data-tag-translator-original="${tag}"]`)
      .contents()
      .filter((i, n) => n.nodeType === Node.TEXT_NODE)
      .each((i, n) => setTagText(n, translation));
  };

  // set up translation editing
  $('[data-tag-translator-original] .retranslate').click(function (evt) { // has to be function for proper `this`
    evt.stopPropagation();
    evt.preventDefault();

    var $this = $(this),
        $parent = $($this.parents('[data-tag-translator-original]')[0]),
        tag = $parent.attr('data-tag-translator-original'),
        translation = $parent.attr('data-tag-translator-current') || tags.get(tag),
        $matching = $(`[data-tag-translator-original="${tag}"]`).contents().filter((i, n) => n.nodeType === Node.TEXT_NODE);

    var translated = window.prompt(
      `Translation for: ${tag}\n\nLeave as-is to cancel, clear text to remove translation`,
      translation);
    if (translated === translation) {
      console.debug(`Translation for "${tag}" unchanged`);
      return; // nothing to do
    } else if (!translated) {
      console.debug(`Deleting translation for "${tag}"`);
      tags.delete(tag);
      translated = tag;
    } else {
      console.debug(`Updating translation for "${tag}" from "${translation}" to "${translated}"`);
      tags.set(tag, translated);
    }

    $matching.each((i, n) => setTagText(n, translated));
  });
}

if (typeof jQuery === 'function') {
  console.debug(`Using local jQuery, version ${jQuery.fn.jquery}`);
  GM_main(jQuery);
} else {
  if (jQuery != null) {
    console.debug('No jQuery found');
  } else {
    console.debug(`jQuery is a ${typeof jQuery}`);
  }
  console.debug('Loading jQuery from Google CDN');
  add_jQuery(GM_main, '3.2.1');
}

function add_jQuery(callbackFn, jqVersion) {
  jqVersion      = jqVersion || "3.2.1";
  var D          = document,
      targ       = D.getElementsByTagName('head')[0] || D.body || D.documentElement,
      scriptNode = D.createElement('script');

  scriptNode.src = `//ajax.googleapis.com/ajax/libs/jquery/${jqVersion}/jquery.min.js`;
  scriptNode.addEventListener('load', function () {
    var scriptNode         = D.createElement('script');
    scriptNode.textContent = 'var gm_jQuery = jQuery.noConflict(true);\n(' + callbackFn.toString() + ')(gm_jQuery);';
    targ.appendChild(scriptNode);
  }, false);
  targ.appendChild(scriptNode);
}