// ==UserScript== // @name Ao3 Auto Bookmarker // @description Allows for autofilled bookmark summary and tags on Ao3. // @namespace Ao3 // @match http*://archiveofourown.org/works/* // @match http*://archiveofourown.org/series/* // @grant none // @version 2.1 // @author Legovil // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/531415/Ao3%20Auto%20Bookmarker.user.js // @updateURL https://update.greasyfork.icu/scripts/531415/Ao3%20Auto%20Bookmarker.meta.js // ==/UserScript== /** * Settings for customizing script behavior. * Allows enabling or disabling specific features. * @type {!Object} */ const settings = { /** @type {boolean} Whether to generate a title note. */ generateTitleNote: true, /** @type {boolean} Whether to generate a summary note. */ generateSummaryNote: true, /** @type {boolean} Whether to check the recommendation box. */ checkRecBox: false, /** @type {boolean} Whether to check the private box. */ checkPrivateBox: false, /** @type {boolean} Whether to retrieve the rating (currently not implemented). */ getRating: true, /** @type {boolean} Whether to retrieve archive warnings. */ getArchiveWarnings: true, /** @type {boolean} Whether to retrieve category tags. */ getCategoryTags: true, /** @type {boolean} Whether to retrieve fandom tags. */ getFandomTags: false, /** @type {boolean} Whether to retrieve relationship tags. */ getRelationshipTags: true, /** @type {boolean} Whether to retrieve character tags. */ getCharacterTags: false, /** @type {boolean} Whether to retrieve additional tags. */ getAdditionalTags: false, /** @type {boolean} Whether to generate a word count tag. */ generateWordCountTag: true, /** @type {boolean} Whether to append generated content to an existing note. */ appendToExistingNote: false, /** @type {boolean} Whether to append generated tags to existing tags. */ appendToExistingTags: false, /** @type {boolean} Whether AO3 extensions are being used. */ usingAo3Extensions: false, }; /** * Word count boundaries used for generating word count tags. * Represents thresholds for different tag categories. * @type {!Array} */ const wordCountBounds = [1000, 5000, 10000, 50000, 100000, 500000]; /** * Enum for bookmark types. * Specifies the type of bookmark being processed. * @enum {string} */ const BookmarkType = Object.freeze({ /** Represents a bookmark for a work. */ WORK: 'WORK', /** Represents a bookmark for a series. */ SERIES: 'SERIES', }); (function() { 'use strict'; // Get all bookmark buttons and attach event listeners for bookmarking on click. const buttons = document.querySelectorAll(".bookmark_form_placement_open"); buttons.forEach(button => button.addEventListener('click', generateBookmark)); })(); /** * Generates a bookmark based on the current page's URL. * Validates the bookmark type and applies corresponding settings. */ function generateBookmark() { const bookmarkType = checkBookmarkType(window.location.href); if (!bookmarkType) { console.error('Bookmark type not found. Cancelling bookmark generation.'); return; } // Apply relevant settings for the determined bookmark type. setNotes(bookmarkType); setTags(bookmarkType); handleCheckBoxes(); } /** * Sets notes based on bookmark type. * @param {string} bookmarkType The type of the bookmark. */ function setNotes(bookmarkType) { const notesElement = document.getElementById('bookmark_notes'); if (!notesElement) { console.error('Notes element not found. Cancelling notes generation.'); return; } notesElement.value = generateNotes(bookmarkType, notesElement); } /** * Sets tags based on bookmark type. * @param {string} bookmarkType The type of the bookmark. */ function setTags(bookmarkType) { const tagsElement = document.getElementById('bookmark_tag_string_autocomplete'); if (!tagsElement) { console.error('Tags input element not found. Cancelling bookmark tag generation.'); return; } tagsElement.value = generateTagsFromType(bookmarkType); } /** * Generates tags based on the bookmark type. * @param {string} bookmarkType The type of the bookmark. * @return {string} The generated tags. */ function generateTagsFromType(bookmarkType) { return bookmarkType === BookmarkType.WORK ? generateTags() : generateSeriesTags(); } /** * Checks the type of bookmark based on the URL. * @param {string} url The URL to check. * @return {string|null} The bookmark type or null if not found. */ function checkBookmarkType(url) { const bookmarkTypes = [ { type: '/works/', result: BookmarkType.WORK, message: 'Found Work Bookmark.' }, { type: '/series/', result: BookmarkType.SERIES, message: 'Found Series Bookmark.' }, ]; const bookmarkType = bookmarkTypes.find(({ type }) => url.includes(type)); if (!bookmarkType) { return null; } console.log(bookmarkType.message); return bookmarkType.result; } /** * Generates notes for the bookmark. * @param {string} bookmarkType The type of bookmark. * @param {!Element} notesElement The notes input element. * @return {string} The generated notes. */ function generateNotes(bookmarkType, notesElement) { const notesArray = [ { setting: settings.generateTitleNote, note: generateTitleNote(bookmarkType) }, { setting: settings.generateSummaryNote, note: generateSummaryNote(bookmarkType) }, ]; const notes = notesArray .filter((noteObj) => noteObj.setting) .map((noteObj) => noteObj.note) .join('\n\n'); // Append or replace existing notes based on settings. return settings.appendToExistingNote ? `${notesElement.value}\n\n${notes}` : notes; } /** * Generates the title note for the bookmark. * @param {string} bookmarkType The type of bookmark. * @return {string} The generated title note. */ function generateTitleNote(bookmarkType) { const queries = { WORK: { title: '.title.heading', author: '.byline.heading a' }, SERIES: { title: 'ul.series a', author: '.series.meta.group a' }, }; const query = queries[bookmarkType]; if (!query) { console.warn(`Invalid bookmark type: ${bookmarkType}. Cancelling Title Note generation.`); return ''; } const { title: titleQuery, author: authorQuery } = query; const title = document.querySelector(titleQuery); if (!title) { console.warn('Title not found. Cancelling Title Note generation.'); return ''; } const author = document.querySelector(authorQuery); if (!author) { console.warn('Author not found. Cancelling Title Note generation.'); return ''; } return `${title.innerHTML.link(window.location.href)} by ${author.outerHTML}.`; } /** * Generates the summary note for the bookmark. * @param {string} bookmarkType The type of bookmark. * @return {string} The generated summary note. */ function generateSummaryNote(bookmarkType) { const queries = { WORK: '.summary.module .userstuff', SERIES: '.series.meta.group .userstuff', }; const summaryQuery = queries[bookmarkType]; if (!summaryQuery) { console.warn(`Invalid bookmark type: ${bookmarkType}. Cancelling summary note generation.`); return ''; } const summary = document.querySelector(summaryQuery); if (!summary) { console.warn('No summary found. Cancelling summary note generation.'); return ''; } return `Summary: ${summary.innerText}`; } /** * Generates tags for the series bookmark. * Extracts tag information from works and generates unique tags. * @return {string} A comma-separated list of generated tags. */ function generateSeriesTags() { const works = Array.from(document.querySelector('.series.work.index.group').children); console.log(works); if (!Array.isArray(works) || works.length === 0) { console.warn( 'No works found or invalid works array. Cancelling tag generation.'); return ''; } const tagTypes = [ { setting: settings.getArchiveWarnings, type: 'warnings', errorMessage: 'Failed to generate Archive Warnings tags. Check to see if you have hide warnings enabled.' }, { setting: settings.getFandomTags, type: 'fandoms.heading', errorMessage: 'Failed to generate Fandom Tags.' }, { setting: settings.getRelationshipTags, type: 'relationships', errorMessage: 'Failed to generate Relationship Tags.' }, { setting: settings.getCharacterTags, type: 'characters', errorMessage: 'Failed to generate Character Tags.' }, { setting: settings.getAdditionalTags, type: 'freeforms', errorMessage: 'Failed to generate Additional Tags.' } ]; const tags = works.flatMap(work => tagTypes .filter(tagType => tagType.setting) .flatMap(tagType => getTagsFromString(tagType, work))); if (settings.generateWordCountTag) { tags.push(generateWordCountTag()); } return Array.from(new Set(tags)).join(', '); } /** * Generates tags for the bookmark. * Removes existing tags unless configured to append to them, * then generates new tags based on settings. * @return {string} A comma-separated list of generated tags. */ function generateTags() { if (!settings.appendToExistingTags) { document.querySelectorAll('.added.tag a').forEach(tagLink => tagLink.click()); } const tagTypes = [ { setting: settings.getArchiveWarnings, type: 'warning.tags', errorMessage: 'Failed to generate Archive Warnings tags. Check to see if you have hide warnings enabled.' }, { setting: settings.getCategoryTags, type: 'category.tags', errorMessage: 'Failed to generate Category Tags.' }, { setting: settings.getFandomTags, type: 'fandom.tags', errorMessage: 'Failed to generate Fandom Tags.' }, { setting: settings.getRelationshipTags, type: 'relationship.tags', errorMessage: 'Failed to generate Relationship Tags.' }, { setting: settings.getCharacterTags, type: 'character.tags', errorMessage: 'Failed to generate Character Tags.' }, { setting: settings.getAdditionalTags, type: 'freeform.tags', errorMessage: 'Failed to generate Additional Tags.' } ]; const tags = tagTypes .filter(tagType => tagType.setting) .flatMap(tagType => getTagsFromString(tagType)); if (settings.generateWordCountTag) { tags.push(generateWordCountTag()); } return tags.join(', '); } /** * Extracts text content from elements with a specific tag type and class name. * @param {!Object} tagType The tag type containing the class name and error message. * @param {string} tagType.type The class name of the tag type to search for. * @param {string} tagType.errorMessage The custom error message to display when no tags are found. * @param {(!Document|!Element)=} startNode The node to begin the search from. Defaults to the document. * @return {string} The concatenated text content from all matching tags, or an empty string if none are found. */ function getTagsFromString(tagType, startNode = document) { const tagList = startNode.querySelectorAll(`.${tagType.type} .tag`); if (tagList.length === 0) { console.warn(tagType.errorMessage); return ''; } return Array.from(tagList, tag => tag.text); } /** * Generates a word count tag based on the word count boundaries. * @return {string} The generated word count tag. */ function generateWordCountTag() { const index = settings.usingAo3Extensions ? 2 : 1; const wordCountElement = document.getElementsByClassName('words')[index]; if (!wordCountElement || wordCountElement.innerText === 'Words:') { console.error('Word count not found. Cancelling word count tag generation.'); return ''; } const wordCount = wordCountElement.innerText.replace(/[, ]/g, ''); let lowerBound = wordCountBounds[0]; if (wordCount < lowerBound) { return `< ${lowerBound}`; } for (const upperBound of wordCountBounds) { if (wordCount < upperBound) { return `${lowerBound} - ${upperBound}`; } lowerBound = upperBound; } return `> ${wordCountBounds[wordCountBounds.length - 1]}`; } /** * Handles the state of checkboxes based on settings. * Updates checkbox elements based on the user's configuration. */ function handleCheckBoxes() { const checkBoxSettings = [ { setting: settings.checkRecBox, elementId: 'bookmark_rec', message: 'Checking rec box.' }, { setting: settings.checkPrivateBox, elementId: 'bookmark_private', message: 'Checking private box.' } ]; checkBoxSettings.forEach(({ elementId, setting, message }) => { const checkBox = document.getElementById(elementId); if (setting && checkBox) { console.log(message); checkBox.checked = true; } }); }