您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds buttons to manage tags EVERYWHERE you want, turns tag-URLs in comments into links, and can show search results as a table
当前为
// ==UserScript== // @name AO3: [Wrangling] Action Buttons Everywhere // @namespace https://greasyfork.org/en/users/906106-escctrl // @description Adds buttons to manage tags EVERYWHERE you want, turns tag-URLs in comments into links, and can show search results as a table // @author escctrl // @version 3.0 // @match *://*.archiveofourown.org/tags/* // @match *://*.archiveofourown.org/comments* // @match *://*.archiveofourown.org/users/*/inbox* // @grant none // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js // @require https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js // @require https://update.greasyfork.org/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js // @license MIT // ==/UserScript== /* eslint-disable no-multi-spaces */ /* global jQuery, lightOrDark, copy2Clipboard */ (function($) { 'use strict'; /*********** INITIALIZING ***********/ const page_type = findPageType(); if (page_type == "") return; // page that isn't supported or we're in retry later // for each button, we need the URL, the icon or text that appears on the button, and a helpful tooltip text // by the way, the working ICONS are here --> https://fontawesome.com/v4/icons const buttons = { search: { link: "/tags/search", icon: "", text: "Search", tooltip: "Search tags", pages: ["top"] }, search_fan: { link: "/tags/search?tag_search[fandoms]=", icon: "", text: "Search this Fandom", tooltip: "Search this Fandom", pages: ["top"] }, new: { link: "/tags/new", icon: "", text: "New Tag", tooltip: "Create new tag", pages: ["top"] }, landing: { link: "", icon: "", text: "Landing Page", tooltip: "Tag Landing Page", pages: ["top", "bin", "inbox", "search"] }, edit: { link: "/edit", icon: "", text: "Edit", tooltip: "Edit tag & associations", pages: ["top", "bin", "inbox", "search"] }, wrangle: { link: "/wrangle?page=1&show=mergers", // since the plain /wrangle page is empty, we might as well go straight to the Syns bin icon: "", text: "Wrangle", tooltip: "Wrangle all child tags", pages: ["top", "bin", "inbox", "search"] }, comments: { link: "/comments", icon: "", text: "Comments", tooltip: "Comments", // this tooltip is overwritten by "Last comment: DATE" when there's a comment pages: ["top", "bin", "inbox", "search"] }, works: { link: "/works", icon: "", text: "Works", tooltip: "Works", pages: ["top", "bin", "inbox", "search"] }, bookmarks: { link: "/bookmarks", icon: "", text: "Bookmarks", tooltip: "Bookmarks", pages: ["top", "bin", "inbox", "search"] }, troubleshoot: { link: "/troubleshooting", icon: "", text: "Troubleshooting", tooltip: "Troubleshooting", pages: ["top"] }, tagcopy: { link: "#", icon: "", text: "Copy Misc", tooltip: "Copy tag to clipboard in various formats", pages: ["top", "bin", "inbox", "search"] }, taglink: { link: "#", icon: "", text: "Copy Link", tooltip: "Copy tag link to clipboard", pages: ["top", "bin", "inbox", "search"] }, tagname: { link: "#" , icon: "", text: "Copy Name", tooltip: "Copy tag name to clipboard", pages: ["top", "bin", "inbox", "search"] }, remove: { link: "", icon: "", text: "Remove", tooltip: "Remove from fandom", pages: ["bin"] } }; let cfg = 'wrangleActionButtons'; // name of dialog and localstorage used throughout let dlg = '#'+cfg; let stored = loadConfig(); // get previously stored configuration createDialog(); if (stored.pages.includes("top") === true && page_type !== "inbox") buildTopButtonBar(); if (stored.pages.includes("bin") === true && page_type == "wrangle") buildBinManageBar(); if (stored.pages.includes("inbox") === true && (page_type == "inbox" || page_type == "comments")) buildCommentsLinkBars(); if (page_type == "search") buildSearchPage(); // adding the script and CSS for icons if (stored.iconify) { $("head").append(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`) .append(`<style type="text/css">#wranglerbuttons, .wrangleactions, .heading .wrangleactions a, .wrangleactions label, a.rss, #new_favorite_tag input[type=submit], form[id^="edit_favorite_tag"] input[type=submit], a#go_to_filters, #resulttable .resultType { font-family: FontAwesome, sans-serif; }</style>`); } $("head").append(`<style type="text/css">a.rss span { background: none; padding-left: 0; } .wrangleactions li { padding: 0; } .wrangleactions input[type='checkbox'] { margin: auto auto auto 0.5em; vertical-align: bottom; } span.wrangleactions a.action { font-size: 91%; padding: 0.2em 0.4em; } .heading span.wrangleactions a.action { font-size: 80%; } .wrangleactions a:visited { border-bottom-color: transparent; } .tags-search span.wrangleactions a { border: 0; padding: 0 0.2em; } #resulttable { line-height: 1.5; ${stored.tableflex ? `width: auto; margin-left:0;` : ''} } #resulttable .resultcheck { display: none; } #resulttable .resultManage { ${(!stored.pages.includes("search") || stored.search.length == 0) ? `display: none;` : ''} } #resulttable tbody .resultManage { padding: 0 0.25em; } #resulttable tbody .resultManage * { margin: 0; } #resulttable .resultType, #resulttable .resultManage { text-align: center; } #resulttable .resultUses { text-align: right; }</style>`); /*********** FUNCTIONS FOR CONFIG DIALOG AND STORAGE ***********/ function findPageType() { // simpler than interpreting the URL: determine page type based on classes assigned to #main let main = $('#main'); return $(main).hasClass('tags-wrangle') ? "wrangle" : $(main).hasClass('tags-edit') ? "edit" : $(main).hasClass('tags-show') ? "landing" : $(main).hasClass('tags-search') ? "search" : $(main).hasClass('tags-new') ? "new" : $(main).hasClass('works-index') ? "works" : $(main).hasClass('bookmarks-index') ? "bookmarks" : $(main).hasClass('comments-index') ? "comments" : $(main).hasClass('comments-show') && $(main).find('.heading a.tag').length === 1 ? "comments" : // comment threads, only proceed if it's on a tag, not a work $(main).hasClass('inbox-show') ? "inbox" : ""; } function createDialog() { // if the background is dark, use the dark UI theme to match let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base"; // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`) .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`) .append(`<style tyle="text/css">.ui-widget, ${dlg}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert !important; line-height: 1.286;} ${dlg} form {box-shadow: revert; cursor:auto;} ${dlg} fieldset {background: revert; box-shadow: revert;} ${dlg} fieldset p { padding-left: 0; padding-right: 0; } ${dlg} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;} ${dlg} li { background-color: revert; border: revert; margin: 0; float: none; clear: none; box-shadow: revert; width: auto; } ${dlg} .sortable li { float: left; } ${dlg} #table-columns.sortable .ui-button { cursor: move; } ${dlg} #table-columns.sortable li.sortfixed { cursor: text; } </style>`); // filling up the enabled buttons with the remaining available - Set() does the job of keeping values unique let order_top = new Set(stored.top); let order_bin = new Set(stored.bin); let order_inbox = new Set(stored.inbox); let order_search = new Set(stored.search); Object.keys(buttons).forEach(function(val) { if (buttons[val].pages.includes("top")) order_top.add(val); if (buttons[val].pages.includes("bin")) order_bin.add(val); if (buttons[val].pages.includes("inbox")) order_inbox.add(val); if (buttons[val].pages.includes("search")) order_search.add(val); }); // wrapper div for the dialog $("#main").append(`<div id="${dlg.slice(1)}"></div>`); // building the dialog content let pages = { top: "Top of Page", bin: "Bins", inbox: "Inbox & Comments", search: "Search" }; let enabled = Object.keys(pages).map(function (me) { return `<label for='enbl${me}'>${pages[me]}</label><input type='checkbox' name='${me}' id='enbl${me}' ${stored.pages.includes(me) ? "checked='checked'" : ""}>`; }); let sort_top = Array.from(order_top.values()).map(function(me) { return `<li><label for='ckbx-top-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-top-${me}' ${stored.top.includes(me) ? "checked='checked'" : ""}></li>`; }); let sort_bin = Array.from(order_bin.values()).map(function(me) { return `<li><label for='ckbx-bin-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-bin-${me}' ${stored.bin.includes(me) ? "checked='checked'" : ""}></li>`; }); let sort_inbox = Array.from(order_inbox.values()).map(function(me) { return `<li><label for='ckbx-inbox-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-inbox-${me}' ${stored.inbox.includes(me) ? "checked='checked'" : ""}></li>`; }); let sort_search = Array.from(order_search.values()).map(function(me) { return `<li><label for='ckbx-search-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-search-${me}' ${stored.search.includes(me) ? "checked='checked'" : ""}></li>`; }); let sort_tablecols = Array.from(stored.tablecols.values()).map(function(me) { return `<li><span class='ui-button ui-widget ui-corner-all'>${me}</span></li>`; }); $(dlg).html(`<form> <fieldset><legend>General Settings:</legend> <p id='enable-page'>Choose where tag wrangling action buttons are added:<br/>${enabled.join(" ")}</p> <p><label for='showicons'>Use Icons instead of Text Labels</label><input type='checkbox' name='showicons' id='showicons' ${stored.iconify ? "checked='checked'" : ""}></p> </fieldset> <p>For each of these places, enable the buttons you wish to use and drag them into your preferred order.</p> <fieldset id='fs-top' ${!stored.pages.includes("top") ? "style='display: none;'" : ""}><legend>${pages.top}</legend> <ul id='sortable-top' class='sortable'>${sort_top.join("\n")}</ul> </fieldset> <fieldset id='fs-bin' ${!stored.pages.includes("bin") ? "style='display: none;'" : ""}><legend>${pages.bin}</legend> <ul id='sortable-bin' class='sortable'>${sort_bin.join("\n")}</ul> </fieldset> <fieldset id='fs-inbox' ${!stored.pages.includes("inbox") ? "style='display: none;'" : ""}><legend>${pages.inbox}</legend> <p>While this is enabled, it'll also turn plaintext URLs into links, and give you a button to turn any text into a taglink.</p> <ul id='sortable-inbox' class='sortable'>${sort_inbox.join("\n")}</ul> </fieldset> <fieldset id='fs-search'><legend>${pages.search}</legend> <ul id='sortable-search' class='sortable' ${!stored.pages.includes("search") ? "style='display: none;'" : ""}>${sort_search.join("\n")}</ul> <p style="clear: left;"><label for='resultstable'>Show Search Results as Table</label><input type='checkbox' name='resultstable' id='resultstable' ${stored.table ? "checked='checked'" : ""}> (works even if the Action buttons on Search aren't enabled)</p> <div style="margin-left: 2em;"> <div id="table-options" ${!stored.table ? "style='display: none;'" : ""}> <p>Drag the columns into your preferred order:</p> <ul id='table-columns' class='sortable'> ${sort_tablecols.join("\n")} <li class="sortfixed"><span class='ui-button ui-widget ui-corner-all ui-state-disabled'>Peek Script</span></li> </ul> <p style="clear: left;"><label for='table-flex'>Resize table dynamically with tags</label><input type='checkbox' name='table-flex' id='table-flex' ${stored.tableflex ? "checked='checked'" : ""}></p> </div> </div> </fieldset> </form>`); // optimizing the size of the GUI in case it's a mobile device let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px) dialogwidth = dialogwidth > 500 ? dialogwidth * 0.7 : dialogwidth * 0.9; $(dlg).dialog({ appendTo: "#main", modal: true, title: 'Wrangling Buttons Everywhere Config', draggable: true, resizable: false, autoOpen: false, width: dialogwidth, position: {my:"center", at: "center top"}, buttons: { Reset: deleteConfig, Save: storeConfig, Cancel: function() { $( dlg ).dialog( "close" ); } } }); // show/hide the corresponding fieldsets when a page is enabled or disabled $('#enable-page input').on('change', function(e) { if (e.target.name == "search") $('#fs-search #sortable-search').toggle(); else $('#fs-'+e.target.name).toggle(); }); // show/hide the table options when displaying search results in a table is enabled or disabled $('input#resultstable').on('change', function(e) { $('#table-options').toggle(); }); // if no other script has created it yet, write out a "Userscripts" option to the main navigation if ($('#scriptconfig').length == 0) { $('#header ul.primary.navigation li.dropdown').last() .after(`<li class="dropdown" id="scriptconfig"> <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a> <ul class="menu dropdown-menu"></ul></li>`); } // then add this script's config option to navigation dropdown $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_${cfg}">Wrangling Buttons Everywhere</a></li>`); // on click, open the configuration dialog $("#opencfg_"+cfg).on("click", function(e) { $( dlg ).dialog('open'); // turn checkboxes and radiobuttons into pretty buttons (must be after 'open' for dropdowns to work) $( dlg+" .sortable input[type='checkbox']" ).checkboxradio({ icon: false }); $( dlg+" input[type='checkbox']" ).checkboxradio(); $( ".sortable" ).sortable({ forcePlaceholderSize: true, tolerance: 'pointer', // makes the movement behavior more predictable revert: true, // animates reversal when dropped where it can't be sorted opacity: 0.5, // transparency for the handle that's being dragged around cancel: '.sortfixed', // disables sorting for elements with this class cursor: "grabbing", // switches cursor while dragging a tag for A+ cursor responsiveness containment: "parent" // limits dragging to the box and avoids scrollbars }); }); } function deleteConfig() { localStorage.removeItem(cfg); $(dlg).dialog('close'); } function storeConfig() { let stored = { pages: [], top: [], bin: [], inbox: [], search: [], iconify: false, table: false, tableflex: false, tablecols: [] }; stored.pages = $(dlg+' #enable-page input:checked').toArray().map((val) => val.name ); // the pages/places where buttons should work stored.top = $(dlg+' #sortable-top input:checked').toArray().map((val) => val.name ); // enabled buttons on top of page in correct order stored.bin = $(dlg+' #sortable-bin input:checked').toArray().map((val) => val.name ); // enabled buttons in the bin in correct order stored.inbox = $(dlg+' #sortable-inbox input:checked').toArray().map((val) => val.name ); // enabled buttons in the inbox in correct order stored.search = $(dlg+' #sortable-search input:checked').toArray().map((val) => val.name ); // enabled buttons on search results in correct order stored.iconify = $(dlg+' input#showicons').prop('checked'); // the question of ICONIFY stored.table = $(dlg+' input#resultstable').prop('checked'); // display search results as table stored.tableflex = $(dlg+' input#table-flex').prop('checked'); // resize table dynamically with tag length stored.tablecols = $(dlg+' #table-columns li span').not('.ui-state-disabled').toArray().map((el) => el.innerText ); // search table columns in correct order localStorage.setItem(cfg, JSON.stringify(stored)); $(dlg).dialog('close'); } function loadConfig() { // gets an Object { pages: [pages], top: [buttons], ..., iconify: boolean, ... } or creates default values if no storage was found return JSON.parse(localStorage.getItem(cfg)) ?? { pages: ["top"], top: ["search", "new", "landing", "edit", "wrangle", "comments", "works", "bookmarks", "troubleshoot", "tagname", "taglink"], bin: ["remove", "edit", "comments", "wrangle", "works"], inbox: [], search: ["edit"], iconify: true, table: true, tableflex: false, tablecols: ["Name", "Type", "Uses", "Manage"] }; } /*********** FUNCTIONS FOR BUTTONS ON TOP OF TAGS PAGES ***********/ function buildTopButtonBar() { const [tag_name, tag_url] = findTag(page_type); let button_bar = ""; stored.top.forEach((val) => { // first, skip over the ones we don't need if (page_type == val && page_type != "search" && page_type != "new") return; // don't link to the page we're on (except on search and new) if ((page_type == "search" || page_type == "new") && val != "search" && val != "new") return; // don't link to tag-specific stuff on search/new if (val == "search_fan") { // only link to fandom-search if it's a fandom tag (and, ideally, canonical) if (page_type == "wrangle" && $('#inner').find('ul.navigation.actions').eq(1).find('li').length != 5) return; else if (page_type == "edit" && !($('#edit_tag').find('fieldset:first-of-type dd').eq(2).text().trim() == "Fandom" && $('#tag_canonical').prop('checked'))) return; else if (page_type == "landing" && $('.tag.home > p').text().search(/^This tag belongs to the Fandom Category\.\s.*canonical/) == -1) return; else if (page_type.search(/^(wrangle|edit|landing)$/) == -1) return; // only link to fandom-search on a bin, edit, landing page } let link = (val.search(/^(new|search|search_fan|taglink|tagname|remove)$/) == -1) ? tag_url : ""; if (val == "search_fan") buttons[val].link += encodeURIComponent(tag_name); button_bar += `<li title="${buttons[val].tooltip}" ${val=="troubleshoot" ? 'class="reindex"' : ""} style="position: relative;"> <a href="${link}${buttons[val].link}" id="wranglerbutton-${val}" ${(val=="taglink" || val=="tagname") ? "data-copyfmt='"+val+"'" : ""}> ${(val=="comments" || val=="works" || val=="bookmarks") ? '<span id="'+val+'Count"></span>' : ""} ${stored.iconify ? buttons[val].icon : buttons[val].text}</a> </li>`; }); let main = $('#main'); // comments doesn't have a navbar yet, we need to create it if (page_type == "comments") { let prevheader = (page_type == "comments" && $(main).find('h2.heading').length == 0) ? $(main).find('h3.heading').prev() : // when clicking through from the inbox $(main).find('h2.heading'); $(`<ul class="navigation actions" role="navigation"'></ul>`).insertAfter(prevheader); } // edit has only the comment link in a <p> so we move it into a <ul> for the sake of simplicity for the rest of the script else if (page_type == "edit") { // so this is complicated, just for making sure any previous buttons with event handlers remain functioning // wrap our required <ul> around the <p>, then remove the <p>, then add <li>s around each link/button $(main).find('p.navigation.actions').wrap('<ul class="navigation actions" role="navigation"></ul>').children().unwrap().wrap('<li></li>'); } // make the navbar ours aka give it an ID (mostly so we can apply ICONIFY) let navbar = $(main).find('ul.navigation.actions'); $(navbar).prop('id', "wranglerbuttons"); // hide the buttons which are already there but we'd be adding again $(navbar).find('li').filter(function() { let link = $(this).find('a'); if (link.length == 1) return $(link).prop('href').match(/\/(works|bookmarks|edit|comments|troubleshooting)$/); else if ($(this).find('span.current').length == 1) return true; else return false; // this keeps all other buttons intact }).hide(); $(navbar).append(button_bar); // works and bookmarks: reduce the FILTER, FAVORITE and RSS buttons on Works page to its icon, if everything else is icons too // on bookmarks page, only the FILTER button applies if (stored.iconify && (page_type == "works" || page_type == "bookmarks")) { $(main).find('.navigation.actions a.rss span').html(""); // RSS already has an icon, just keep that without text $(main).find('#new_favorite_tag input[type="submit"]').val("\u{f004}").attr("title", "Favorite Tag"); $(main).find('form[id^="edit_favorite_tag"] input[type="submit"]').val("\u{f1f8}").attr("title", "Unfavorite Tag"); $(main).find('a#go_to_filters').html("").attr("title", "Open Filters sidebar"); } // set the click-events for the copy-to-clipboard buttons $('#wranglerbuttons').on('click', '#wranglerbutton-tagname, #wranglerbutton-taglink, .wrangleactions-choosefmt', (e) => { e.preventDefault(); e.stopPropagation(); setTextToCopy(e, tag_url, tag_name); }); $('#wranglerbuttons').on('click', '#wranglerbutton-tagcopy', (e) => { e.preventDefault(); addLinkFormatChoice(e.target); }); loadWorksBookmarksCommentsCounts(); } function findTag(p) { let name = (p == "landing") ? $('div.tag h2.heading').text() : (p == "search" || p == "new" || p == "inbox") ? "" : $('#main > .heading a.tag').text(); let link = (p == "landing") ? window.location.origin+window.location.pathname : (p == "search" || p == "new" || p == "inbox") ? "" : $('#main > .heading a.tag').prop('href'); // monitoring if there are ever multiple tags found. some pages use h2, some h3 if (!(p == "landing" || p == "search" || p == "new" || p == "inbox") && $('#main > .heading a.tag').length !== 1) { console.log($('#main > .heading a.tag')); alert($('#main > .heading a.tag').length + ' tag headings found, check console'); } return [name, link]; } function loadWorksBookmarksCommentsCounts() { // a variable to store our XMLHttpRequests let find = {comments: null, works: null, bookmarks: null}; const [tag_name, tag_url] = findTag(page_type); // find the comment info if ($("#wranglerbutton-comments").length > 0) { // most pages, except works and bookmarks, have the number and date already on a button if (!(page_type == "works" || page_type == "bookmarks")) { printComment($('.navigation.actions a[href$="/comments"]').not("#wranglerbutton-comments").text()); } // other pages we have to load it in the background - we use the landing page else { find.comments = $.ajax({ url: tag_url, type: 'GET' }) .fail(function(xhr, status) { find.comments = null; cancelAllPageLoads(find, status); }).done(function(response) { find.comments = null; printComment($(response).find('.navigation.actions a[href$="/comments"]').text()); }); } } // find the works and bookmarks info - only on the edit page of a canonical tag if (page_type == "edit" && $('#tag_canonical').prop('checked') == true) { const [tag_name, tag_url] = findTag(page_type); // bookmarks always need to be loaded, no way to tell from the canonical page if ($("#wranglerbutton-bookmarks").length > 0) { find.bookmarks = $.ajax({ url: tag_url+buttons.bookmarks.link, type: 'GET' }) .fail(function(xhr, status) { find.bookmarks = null; cancelAllPageLoads(find, status); }).done(function(response) { find.bookmarks = null; // .contents().first() grabs only the textNode "1-20 of X Bookmarks in" instead of the whole heading text printBookmarks($(response).find('#main h2.heading').contents().first().text()); }); } if ($("#wranglerbutton-works").length > 0) { // if the tag has no syns or subs, use the values from the sidebar if ($('#child_SubTag_associations_to_remove_checkboxes').length == 0 && $('#child_Merger_associations_to_remove_checkboxes').length == 0) { if ($("#wranglerbutton-works").length > 0) printWorks($('#inner > #dashboard a[href$="/works"]').text(), "link"); } // otherwise load the pages in the background to get the totals else { find.works = $.ajax({ url: tag_url+buttons.works.link, type: 'GET' }) .fail(function(xhr, status) { find.works = null; cancelAllPageLoads(find, status); }).done(function(response) { find.works = null; // .contents().first() grabs only the textNode "1-20 of X Works in" instead of the whole heading text printWorks($(response).find('#main h2.heading').contents().first().text(), "title"); }); } } } } function printComment(button_cmt) { $("#wranglerbutton-comments #commentsCount").text(button_cmt.match(/^\d+/)[0]); if ($("#wranglerbutton-comments #commentsCount").text() !== "0") $("#wranglerbutton-comments").parent().attr('title', "Last comment: "+ button_cmt.match(/last comment: (.*)\)$/)[1] ); } function printWorks(text, source) { if (source == "link") text = parseInt(text.match(/\d+/)[0]).toLocaleString('en'); // sidebar has no thousands separator else text = text.match(/([0-9,]+) Work/)[1]; // title already has a thousands separator $("#wranglerbutton-works #worksCount").text(text); } function printBookmarks(text) { text = text.match(/([0-9,]+) Bookmarked/)[1]; // title already has a thousands separator $("#wranglerbutton-bookmarks #bookmarksCount").text(text); } function cancelAllPageLoads(xhrs, status) { if (status == "abort") return; // avoid a loop by this being called due to the xhr being aborted from other failed pageload // abort all the potential background pageloads if (xhrs.comments !== null) $(xhrs.comments).abort("Retry Later"); if (xhrs.works !== null) $(xhrs.works).abort("Retry Later"); if (xhrs.bookmarks !== null) $(xhrs.bookmarks).abort("Retry Later"); } /*********** FUNCTIONS FOR BUTTONS ON TAG COMMENTS IN INBOX ***********/ function buildCommentsLinkBars() { addButtonName2Link(); if (page_type == "inbox") { // turn any plaintext URLs in the comment into links $('ol.comment.index li.comment .userstuff > *').not('a').each((i, el) => plainURI2Link(el)); // create the buttons bar for all existing links $('ol.comment.index li.comment .heading a, ol.comment.index li.comment .userstuff a').filter('[href*="/tags/"]').each((i, a) => addCommentLinkBar(a)); } if (page_type == "comments") { // turn any plaintext URLs in the comment into links $('#comments_placeholder li.comment .userstuff > *').not('a').each((i, el) => plainURI2Link(el)); // create the buttons bar for all existing links $('#comments_placeholder li.comment .userstuff a[href*="/tags/"]').each((i, a) => addCommentLinkBar(a)); } // set the click-events for the copy-to-clipboard buttons $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink, .wrangleactions-choosefmt', (e) => { e.preventDefault(); e.stopPropagation(); // grab the URL from the <a> in front, but cut it off at the tag name so we don't include the /comments/commentID at the end let link = $(e.target).parents('.wrangleactions').first().prev().prop('href').match(/.*\/tags\/[^\/]*/)[0]; let name = $(e.target).parents('.wrangleactions').first().prev().text(); setTextToCopy(e, link, name); }); $('#main').on('click', '.wrangleactions-tagcopy', (e) => { e.preventDefault(); addLinkFormatChoice(e.target); }); } function plainURI2Link(el) { // first we test for a construct of <a href="URL">URL</a> and turn the text readable $(el).find('a[href*="/tags/"]').each((i, a) => { if (a.innerText.startsWith("http")) { // from the HREF attribute, we grab the tag name into Group 1 (still URI encoded) let tagEnc = a.href.match(/\/tags\/([^\/]*)/)[1]; // we decode that into a readable tag name let tagDec = decodeURIComponent(tagEnc).replace('*s*','/').replace('*a*','&').replace('*d*','.').replace('*h*','#').replace('*q*','?'); // and re-build this into a readable link $(a).text(tagDec).prop('href', `https://archiveofourown.org/tags/${tagEnc}`); } }); // after linked URLs were fixed, we test for unlinked URLs in text // ?<! ... ) is a "negative lookbehind" RegEx and ensures we don't pick up any URIs in a <a> href property el.innerHTML = el.innerHTML.replaceAll(/(?<!href=["'])https:\/\/.*?archiveofourown\.org\/tags\/[^<>\s]*/g, function (x) { // from the URL, we grab the tag name into Group 1 (still URI encoded) let tagEnc = x.match(/https:\/\/.*?archiveofourown\.org\/tags\/([^\/\s]+)/)[1]; // we decode that into a readable tag name let tagDec = decodeURIComponent(tagEnc).replace('*s*','/').replace('*a*','&').replace('*d*','.').replace('*h*','#').replace('*q*','?'); // and re-build this into a readable link return `<a href="https://archiveofourown.org/tags/${tagEnc}">${tagDec}</a>`; }); } function addButtonName2Link() { // add a button on each tag comment's actionbar - has to be a <button> since <a> would overwrite the getSelection() let createlink = page_type === "inbox" ? $('li.comment').filter(function (ix, el) { return $(this).find('.byline a[href*="/tags/"]').length === 1; }).find('ul.actions') : $('li.comment ul.actions'); // script anyhow only runs on tag comment pages $(createlink).prepend(`<li><button class="name2link" type="button">Create Tag Link</button></li>`); // when button is clicked, find current text highlight and turn it into a link to the tag $('#main').on('click', 'button.name2link', (e) => { let sel = document.getSelection(); // selection has to be a range of text to work, but mustn't cross a <br> or <p> for example, and has to be within a comment text if (sel.type == "Range" && sel.anchorNode == sel.focusNode && $(sel.anchorNode).parents('li.comment blockquote.userstuff').length > 0) { // create the corresponding URL for the highlighted text let link = encodeURI("https://archiveofourown.org/tags/" + sel.toString().replace('/', '*s*').replace('&', '*a*').replace('#', '*h*').replace('.', '*d*').replace('?', '*q*')); // wrap the highlighted text in an <a> let a = document.createElement("a"); a.href = link; sel.getRangeAt(0).surroundContents(a); // add the button bar after the inserted <a> $(a).after(addCommentLinkBar(a)); // set the click-events for the copy-to-clipboard buttons $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink', (e) => { let tag = $(e.target).parent().prev(); let str = $(e.target).hasClass("wrangleactions-tagname") ? $(tag).text() : `<a href="${$(tag).prop('href')}">${$(tag).text()}</a>`; copy2Clipboard(e, str); }); // remove the text highlighting sel.removeAllRanges(); } else alert ('Please select some text in a comment first'); }); } function addCommentLinkBar(a) { // cut off the link text at the tag name so we're not linking to /edit pages or /comments/commentID // this makes the button to landing pages unnecessary! let link = $(a).prop('href').match(/.*\/tags\/[^\/]*/)[0]; $(a).prop('href', link); let actions_bar = ` <span class="wrangleactions" style="position: relative;">`; stored.inbox.forEach((val) => { let label = stored.iconify ? buttons[val].icon : buttons[val].text; actions_bar += `<a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}" ${(val=="taglink" || val=="tagname") ? "data-copyfmt='"+val+"'" : ""} title="${buttons[val].tooltip}" class="action wrangleactions-${val}">${label}</a> `; }); actions_bar += "</span>"; $(a).after(actions_bar); } /*********** FUNCTIONS FOR BUTTONS IN BIN TABLE ***********/ function buildBinManageBar() { let unwrangled = new URLSearchParams(document.location.search).get('status') == "unwrangled" ? true : false; // working our way down the table $('#wrangulator table tbody tr').each((i, row) => { let link = $(row).find("a[href$='/edit']").prop('href').slice(0,-5); let og_edit_link = $(row).find("a[href$='/edit']").attr('href'); // pick up the existing buttons bar // we could be replacing it entirly with the new buttons, but that might break the Comment from Bins script if it already added the button let actions_bar = $(row).find('td:last-of-type ul.actions').addClass('wrangleactions'); // create the list of new buttons let new_buttons = []; stored.bin.forEach((val) => { let label = stored.iconify ? buttons[val].icon : buttons[val].text; if (val == "remove") { if (!unwrangled) { // if on an unwrangled page, we skip the remove button let remove_btn = $(actions_bar).find('li:first-child').clone().get(0); // clone the original remove button (easier) remove_btn.innerHTML = remove_btn.innerHTML.replace(/Remove/, label); // html replace so the icon will work new_buttons.push(remove_btn.outerHTML); } } else { new_buttons.push(`<li title="${buttons[val].tooltip}" style="position: relative;"> <a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}" class="wrangleactions-${val}" ${(val=="taglink" || val=="tagname") ? "data-copyfmt='"+val+"'" : ""}> ${label}</a></li>`); } }); // figuring out the position of the Add Comment button (Comment from Bins script) let pos_divider = $(actions_bar).children('li[title="Add Comment"]').index(); // is -1 if button doesn't exist // if the divider exists, we want to hook it in right after the comments button, so where is that? if (pos_divider > -1) { let pos_cmt_btn = stored.bin.indexOf('comments'); // is -1 if button doesn't exist // if there is a comment button, we might need to shift its index on unwrangled pages where the "remove" button is being skipped if (pos_cmt_btn > -1) pos_divider = (unwrangled && stored.bin.indexOf('remove') < pos_cmt_btn) ? pos_cmt_btn-1 : pos_cmt_btn; // if there is no comment button, we want to hook it in at its original position (counted from the end) else pos_divider = pos_divider - $(actions_bar).children().length; } // empty out the buttons bar except any existing Add Comment button $(actions_bar).children().not('li[title="Add Comment"]').remove(); // if no Add Comment button exists just dump them all in if (pos_divider == -1) $(actions_bar).prepend(new_buttons.join(' ')); // wrap the new buttons around the existing Add Comment button so that Add Comment follows the Comments link else { $(actions_bar).prepend(new_buttons.slice(0, pos_divider+1).join(' ')); $(actions_bar).append(new_buttons.slice(pos_divider+1).join(' ')); } // add a hidden Edit button at the beginning no matter (for other scripts and for the Copy Link option) $(actions_bar).prepend(`<li style="display: none;" title="Edit"><a href="${og_edit_link}">Edit</a></li>`); }); // set the click-events for the copy-to-clipboard buttons $('#wrangulator').on('click', '.wrangleactions-tagname, .wrangleactions-taglink, .wrangleactions-choosefmt', (e) => { e.preventDefault(); e.stopPropagation(); let name = $(e.target).parents('tr').first().find("th label").text(); let link = $(e.target).parents('td').first().find("a[href$='/edit']").first().prop('href').slice(0,-5); setTextToCopy(e, link, name); }); $('#wrangulator').on('click', '.wrangleactions-tagcopy', (e) => { e.preventDefault(); addLinkFormatChoice(e.target); }); } /*********** FUNCTIONS FOR BUTTONS ON SEARCH RESULTS ***********/ // this replaces the whole table logic of the old script. in the old script, only the highlighting will remain (which barely anyone ever uses) // it does no longer supports sorting on the page itself, since sorting options have been added in the search form itself function buildSearchPage() { if (stored.table) buildSearchTable(); else if (stored.pages.includes("search") === true) { // working our way down the list $('a.tag').each((i, a) => { // create buttons bar and put it in front of the line let actions_bar = buildSearchManageBars(a); $(a).parent().before(actions_bar); }); } // set the click-events for the copy-to-clipboard buttons $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink, .wrangleactions-choosefmt', (e) => { e.preventDefault(); e.stopPropagation(); let name = $(e.target).parents("li, tr").find("a.tag").text(); let link = $(e.target).parents("li, tr").find("a.tag").prop('href'); setTextToCopy(e, link, name); }); $('#main').on('click', '.wrangleactions-tagcopy', (e) => { e.preventDefault(); addLinkFormatChoice(e.target); }); } function buildSearchTable() { let header = `<thead><tr>`; stored.tablecols.forEach((val) => { header += `<th scope="col" class="result${val}">${val}</th>`; }); header += `<th scope="col" class="resultcheck">Fandom/Synonym</th></tr></thead>`; // as always, a list of available icons is here --> https://fontawesome.com/v4/icons const typetext = (stored.iconify) ? { UnsortedTag: "", Fandom: "", Character: "", Relationship: "", Freeform: "", "Media": "", "Rating": "", "ArchiveWarning": "", "Category": "" } : { UnsortedTag: "?", Fandom: "F", Character: "Char", Relationship: "Rel", Freeform: "FF", "Media": "Media", "Rating": "Rating", "ArchiveWarning": "Warn", "Category": "Cat" }; // the search results are in an <ol> under an <h4> // the individual result within the li is in another (useless) span, with text before (the type), a link (the tag), and text after (the count) let results = $('h4 ~ ol.tag li>span').not('.wrangleactions').toArray().map((result) => { let cols = {}; // where we keep the columns content: "Name", "Type", "Uses", "Manage" let tag = $(result).children('a')[0]; // the <a> containing the tag's name and link let line = result.innerText.split(' '); // simple string manipulations are faster than complicated RegEx // if the script to find illegal chars is running, it might have added a <div class="notice"> let illegalchar = $(result).nextAll("div.notice").length > 0 ? $(result).nextAll("div.notice")[0].outerHTML : ""; // if the tag is canonical, the span has a corresponding CSS class cols.Name = `<th scope="row" class="resultName ${$(result).hasClass('canonical') ? " canonical" : ""}">${tag.outerHTML} ${illegalchar}</th>`; // first word (minus the colon) is the type line[0] = line[0].slice(0, -1); cols.Type = `<td title="${line[0]}" class="resultType">${typetext[line[0]]}</td>`; // last word (minus the parenthesis) is the count cols.Uses = `<td class="resultUses">${line[line.length-1].slice(1, -1)}</td>`; // create this tag's action buttons bar cols.Manage = `<td class="resultManage">${ stored.pages.includes("search") === true ? buildSearchManageBars(tag) : "" }</td>`; // join them all in the configured order let colsOrdered = `<tr>`; stored.tablecols.forEach((val) => { colsOrdered += cols[val]; }); colsOrdered += `<td class="resultcheck"> </td></tr>`; return colsOrdered; }); let body = `<tbody>${results.join("\n")}</tbody>`; $('h4.landmark.heading:first-of-type').after(`<table id="resulttable">${header}${body}</table>`); // hide the type column if we've searched for a specific type (of course they'll all be the same then) var searchParams = new URLSearchParams(window.location.search); var search_type = searchParams.has('tag_search[type]') ? searchParams.get('tag_search[type]') : ""; if (search_type != "") { $('#resulttable .resultType').hide(); $('#resulttable thead .resultName').prepend(search_type+": "); } $('h4 ~ ol.tag').hide(); // hide the original results list } function buildSearchManageBars(a) { let link = $(a).prop('href'); let actions_bar = ` <ul class="wrangleactions actions" style="float: none; display: inline-block;">`; stored.search.forEach((val) => { let label = stored.iconify ? buttons[val].icon : buttons[val].text; actions_bar += `<li style="position: relative;"><a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}" title="${buttons[val].tooltip}" ${(val=="taglink" || val=="tagname") ? "data-copyfmt='"+val+"'" : ""} class="action wrangleactions-${val}">${label}</a> </li>`; }); actions_bar += "</ul>"; return actions_bar; } /*********** FUNCTIONS FOR COPYING LINK FORMAT CHOICE ***********/ const copybuttons = { text: { icon: "", text: "Text", tooltip: "Tag as Text" }, comments: { icon: "", text: "Comment", tooltip: "Link for AO3 Comments" }, chat: { icon: "", text: "Chat", tooltip: "Link for Chat" }, wiki: { icon: "", text: "Wiki", tooltip: "Link for OTW Wiki" }, }; let copybuttonbar = `<div class="wrangleactions-choosefmt actions" style="position: absolute; right: 0; top: 2em; z-index: 20; width: max-content; text-align: left; background-color: ${$('body').css('background-color')}; border: 1px solid ${$('button').css('background-color')}; padding: 0.5em;">`; for (let [key, val] of Object.entries(copybuttons)) { copybuttonbar += `<a href="#" data-copyfmt="${key}" title="${val.tooltip}">${stored.iconify ? val.icon : val.text}</a> `; } copybuttonbar += `<a href="#" title="Cancel">×</a></div>`; function addLinkFormatChoice(el) { $(".wrangleactions-choosefmt").remove(); // remove any other open ones $(el).after(copybuttonbar); } function setTextToCopy(e, link, name) { let txt, html; let linkfmt = e.target.dataset.copyfmt; if (linkfmt === "comments" || linkfmt === "taglink") { txt = `<a href="${link}">${name}</a>`; copy2Clipboard(e, "fmt", txt); } else if (linkfmt === "chat") { txt = `${name} ${link}`; html = `<a href="${link}">${name}</a>`; copy2Clipboard(e, "fmt", txt, html); } else if (linkfmt === "wiki") { txt = `[${link} ${name}]`; copy2Clipboard(e, "txt", txt); } else if (linkfmt === "text" || linkfmt === "tagname") { copy2Clipboard(e, "txt", name); } // if linkfmt == "cancel", the dialog only closes $(".wrangleactions-choosefmt").remove(); } })(jQuery);