// ==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
// ==/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<number>}
*/
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;
}
});
}