// ==UserScript== // @name Fanfiction Tools // @author Ewino // @version 1.4 // @description Enhances fanfiction.net. // @namespace http://userscripts.org/scripts/show/102342 // @include http://*.fanfiction.net/* // @updateURL http://userscripts.org/scripts/source/102342.meta.js // @history 1.4 Introduced language filtering, shortened the new favs/follows line, and started using GM's built-in updater. // @history 1.39 A new fix for the site changes. Thanks afoongwl! // @history 1.38 No major update yet. Adapting to the site's new layout. // @history 1.37 Added the average update interval. // @history 1.36 Fixed a bug in Firefox 3.6 where the menu would not open // @history 1.35 Fixed a bug (following a site update) where marking information in several lists (like a user's favorite stories tab) didn't work. // @history 1.3 Added options to auto-close menus only on click, and not hide the chapter navigator (also fixed a small bug with list auto-loading) // @history 1.2 Added an options window and fixed a small bug. // @history 1.1 Fixed a bug with updating the url-hashes. // @history 1.05 FF.net introduced "Share" links in the beginning of each chapter, which broke the auto-loading feature. This fixes it. // @history 1.0 First version. Rewritten from "Power Fanfiction.net" by Ultimer (http://userscripts.org/scripts/show/61979) // ==/UserScript== /*jshint smarttabs:true */ /*global utils:true, features:true, GM_setValue: true, GM_getValue: true, GM_addStyle: true, unsafeWindow: true*/ /* * IMPORTANT: * ==================== * For all those who are looking to change the script's settings, there's now a menu called * "Fanfiction Tools Options" at the top right of every ff.net window (right next to the site's "help" menu) * Click on it to access your settings. * ==================== */ var settings = {}; /* DO NOT CHANGE THIS! IT WILL DO NOTHING */ var defaultSettings = { colorDate: true, /** Color the dates. [true/false] */ colorComplete: true, /** Add a unique color to completed stories. [true/false] */ dateFormat: 1, /** Format of the date. [0/1] 0:US date format, 1:UK date format */ dateOrder: 0, /** Order of the date. [0/1] 0: Published-Updated, 1: Updated-Published */ sep: '/', /** Separator of the dates. (i.e. '-' would result in 1-2-2000). */ fullStoryLoad: false, /** Load the entire story when opening a story. [true/false] */ loadAsYouGo: true, /** Load the next chapter as you read [true/false]. Ignored when fullStoryLoad: true. */ loadListsAsYouGo: true, /** Load the next page in a list as you pass through it. [true/false] */ markWords: 'rape|death|MPREG', /** Words that are marked in the story summary. [an array of words] */ combineReview: true, /** Combine the review link with the review count. [true/false] */ shouldMenusAutoClose: 1, /** use auto-closing menu. [0/1/2] 0: Never auto-close, 1: on mouse leaving, 2: on clicking elsewhere */ shouldRelativeDate: true, /** use relative dates. [true/false] */ showPostingFrequency: true, /** show the average posting frequency. [true/false] */ hideChaptersNavigator: true, /** Hide the chapters drop-down menu. [true/false] */ shortenFavsFollows: true, /** Whether to shorten favs/follows info to small symbols. [true/false] */ viewLanguages: '' /** The preferred languages. Stories not of these languages won't be shown (disabled if empty) [an array of language names */ }; var env = { /** The chapter we currently view. */ currentChapter: 1, /** The amount of chapters in this story. */ totalChapters: 1, /** The current chapter, or the one last loaded through AJAX. */ lastLoadedChapter: -1, /** The URL to load the next page in a paged list. */ nextListPageUrl: '', /** The ~real~ window element. The unsafe one if we use GM */ w: (unsafeWindow || window), /** A shortcut function to use for logging */ log: null }; env.log = env.w.console.log; /** This will be called after the environment finishes initialization. it's the start-point. */ function load() { // for some reason we also run for the sharing iframe. this prevents that. if (!unsafeWindow || unsafeWindow.location.host.indexOf('fanfiction.net') === -1) { return; } features.settings.load(); loadJQuery(); if (settings.shouldMenusAutoClose) { features.setAutoClosingMenus(settings.shouldMenusAutoClose === 2); } unsafeWindow.storylist_render = features.favStoriesRender; features.optionsMenu.setup(); // all selectors var chapterNavigator = utils.getChapterNavigator(), storyTextEl = $('#storytext'), zlists = $('.z-list'); if (storyTextEl.length > 0) { // we're in a story page if (chapterNavigator.length > 0) { // the story has multiple chapters! if ((settings.loadAsYouGo || settings.fullStoryLoad) && settings.hideChaptersNavigator) { chapterNavigator.hide(); } env.totalChapters = utils.chapters.getCount(); var currentChapter = env.currentChapter = utils.chapters.getCurrent(); // setup dom helper elements storyTextEl.prepend('').append(''); $('#story-start').after(features._getChapterSeparator(currentChapter, null)); var currentSep = $('header.greasemonkey-chapter-separator:first').addClass('current-chapter'); features._addEndOfStorySeperatorIfNeeded(currentChapter); $('.a2a_kit').prependTo(currentSep); var hash = utils.getLocationHash(); if (hash !== '' && isFinite(hash) && hash > currentChapter) { features.redirectToChapter(hash); return; } if (settings.fullStoryLoad) { features.autoLoad.loadFullStory(); } else if (settings.loadAsYouGo) { features.setLoadAsYouGo(); } } features.formatting.doStoryPageInfo($('table#gui_table1i tr.alt2 > td div:contains(Rated:)').last()); } else if (zlists.length > 0) { // we're in a page containing lists zlists.each(function() { features.formatting.doListEntry(this); }); if (settings.loadListsAsYouGo) { features.autoLoad.autoLoadLists(); } } } /***************** Features *****************/ features = { /** * Makes the top menus close when the mouse pointer leaves them. */ setAutoClosingMenus: function(onlyOnClick) { $('.zui').append(''); env.w.xmenu_render(); // Force a re-render var eventType = onlyOnClick ? 'click' : 'mouseover'; $('#content_parent')[0].addEventListener(eventType, function() { $('#menu-Dummylink').mouseover(); }, false); }, /** * This is a copy of the original storylist_render function, but with some (highlighted) changes. * It is used to write certain lists like a user's Favorite Stories list. * Note: some unhighlighted changes are that some functions have to be called now through the unsafeWindow. */ favStoriesRender: function (story_array, startrow, show_cat, show_author) { var buffer = []; var story; for(var i = story_array.length, c = startrow; i > 0; i--, c++) { story = story_array[i-1]; /*************************** CHANGE ***************************/ /* Here we store the old buffer in a temp var and use our own buffer for this single entry html. */ var mainBuffer = buffer; buffer = []; /************************ END OF CHANGE ***********************/ buffer[buffer.length] = c + ". "; buffer[buffer.length] = story.stitle + " "; if(story.chapters > 1) { buffer[buffer.length] = " » "; } if(show_author === 1) { buffer[buffer.length] = "by " + story.penname + " "; } if(story.ratingtimes > 0) { buffer[buffer.length] = "reviews"; } buffer[buffer.length] = "
" + story.summary + "
"; if(show_cat === 1) { if(story.crossover > 0) { buffer[buffer.length] = "Crossover - " + story.category + " - "; } else { buffer[buffer.length] = story.category + " - "; } } buffer[buffer.length] = unsafeWindow.array_censors[story.censorid] + " - "; buffer[buffer.length] = unsafeWindow.array_languages[story.languageid] + " - "; if(story.genreid > 0) { buffer[buffer.length] = unsafeWindow.array_genres[story.genreid]; if(story.subgenreid > 0) { buffer[buffer.length] = "/" + unsafeWindow.array_genres[story.subgenreid]; } } else if(story.subgenreid > 0) { buffer[buffer.length] = unsafeWindow.array_genres[story.subgenreid] + " - "; } buffer[buffer.length] = " - Chapters: " + story.chapters + " - Words: " + unsafeWindow.addCommas(story.wordcount) + " - Reviews: " + story.ratingtimes + " - Updated: " + story.dateupdatetext + " - Published: " + story.datesubmittext; if(story.chars.length) { buffer[buffer.length] = " - " + story.chars; } if(story.statusid === 2) { buffer[buffer.length] = " - Complete"; } buffer[buffer.length] = "
"; /*************************** CHANGE ***************************/ /* Now that we have the entry's html, we can reformat it like any normal list entry. * Also, return the buffer to be what it was. */ var entryEl = $('
').html(buffer.join(''))[0]; features.formatting.doListEntry(entryEl); buffer = mainBuffer; buffer.push(entryEl.innerHTML); /************************ END OF CHANGE ***********************/ } return buffer.join(""); }, shouldHideListEntry: function(listEntry) { if (settings.viewLanguages == null || settings.viewLanguages.length === 0) { return false; } var html = listEntry.html(); var matcher = html.match(/Rated: .*? - (.*?) - /); if (!matcher) { return false; } if (matcher[1]) { var storyLang = matcher[1].toLowerCase(); for (var i = 0; i < settings.viewLanguages.length; i++) { var vl = settings.viewLanguages[i].toLowerCase(); if (vl === storyLang) { return false; } } return true; } }, formatting: { doStoryPageInfo: function(elementToFormat) { elementToFormat.attr('id', 'details-line'); this.formatInfo(elementToFormat); }, doListEntry: function(listEntry) { listEntry = $(listEntry); if (features.shouldHideListEntry(listEntry)) { listEntry.remove(); return false; } var reviewLink = listEntry.children('a[href^="/r/"]'); if (settings.combineReview) { reviewLink.hide(); } var reviewsUrl = reviewLink.attr('href') || ''; if (reviewsUrl === '') { var storyLink = listEntry.children('a[href^="/s/"]'); var storyUrl = storyLink.attr('href') || ''; var urlMatch = storyUrl.match(/\/s\/(\d+)/); if (urlMatch && urlMatch[1]) { var storyId = urlMatch[1]; reviewsUrl = '/r/' + storyId + '/'; } } listEntry[0].innerHTML = utils.markWords(listEntry[0].innerHTML, settings.markWords); this.formatInfo(listEntry.find('.z-padtop2,.gray'), reviewsUrl); return true; }, formatInfo: function(detailsLine, reviewsUrl) { // arguments verification. if ((detailsLine = $(detailsLine)).length === 0) { return; } if (!reviewsUrl) { reviewsUrl = detailsLine.children('a[href^="/r/"]').attr('href') || ''; } var html = detailsLine[0].innerHTML, bCompleted = false, matcher; // for general purposes :) if (settings.viewLanguages != null && settings.viewLanguages.length === 1 && settings.viewLanguages[0].indexOf('-') === -1) { html = html.replace(' - ' + settings.viewLanguages[0] + ' - ', ' - '); } matcher = html.match(/Rated: (]+>)?(Fiction\s+)?([\w+]+)(<\/a>)?/); if (matcher) { html = html.replace(matcher[0], features.formatting._getRatingTagString(matcher[3])); } // if a Complete text exists, remove it and keep note of it. matcher = html.match(/[\s\-]*(Status: )?Complete([\s\-]*)/); if (matcher) { bCompleted = true; html = html.replace(matcher[0], matcher[2]); } // set the review link matcher = html.match(/Reviews: (]+>)?([\d,]+)(<\/a>)?/); if (matcher) { var reviewLink = 'Reviews: ' + utils.getReadableNumber(matcher[2]); if (reviewsUrl) { reviewLink = '' + reviewLink + ''; } html = html.replace(matcher[0], reviewLink); } var totalChapters = env.totalChapters; if (env.totalChapters === 1) { totalChapters = 0; } // set the words/chapter matcher = html.match(/Chapters: ([\d,]+) - Words: ([\d,]+)/); if (matcher) { totalChapters = utils.parseNum(matcher[1]); var wordsPerChapter = utils.getReadableNumber(Math.round( utils.parseNum(matcher[2]) / totalChapters )); html = html.replace(matcher[0], 'Size: ' + matcher[1] + '/' + utils.getReadableNumber(matcher[2]) + ''); } // format favorites and followers text matcher = html.match(/( - Favs: [\d,]+| - Follows: [\d,]+){1,2}/); // either favs, follows, or both in either order if (matcher) { var orig = matcher[0]; var favs = (matcher = orig.match(/Favs: ([\d,]+)/)) ? matcher[1] : 0, follows = (matcher = orig.match(/Follows: ([\d,]+)/)) ? matcher[1] : 0; html = html.replace(orig, features.formatting._getFavsFollowsString(utils.parseNum(favs), utils.parseNum(follows))); } matcher = html.match(/ - (Updated: (\d+-\d+-\d+) - )?Published: (\d+-\d+-\d+)/); if (matcher) { var publishDate = matcher[3], updateDate = matcher[2]; html = html.replace(matcher[0], features.formatting._getUpdatedPublishedString(publishDate, updateDate, bCompleted, totalChapters)); } detailsLine[0].innerHTML = html; }, /** * Gets the " - Updated: x/y/z - Published: a/b/c" string, formatted per the user's settings. * @param publishDate The raw FF.net formatted date of this story's publishing. * @param updateDate The raw FF.net formatted date of this story's last update (or null, for single chapter stories). * @param completed whether this story is complete. * @param totalChapters The number of total chapters in the story. */ _getUpdatedPublishedString: function(publishDate, updateDate, completed, totalChapters) { publishDate = utils.dates.parseFFDate(publishDate); updateDate = updateDate ? utils.dates.parseFFDate(updateDate) : null; // in case there was no update date specified (i.e. for a oneshot or a story that was never updated) if (!updateDate) { return ' - ' + utils.dates.formatDateExtended(publishDate, completed, 'Published: '); } var avgPostingFrequency = 0; if (totalChapters > 0) { var storyLifespan = (updateDate - publishDate) / 1000 / 24 / 3600; avgPostingFrequency = storyLifespan / totalChapters; } // declare the final strings. var publishedPart = ' - Published: ' + utils.dates.formatDate(publishDate), updatedPart = ' - ' + utils.dates.formatDateExtended(updateDate, completed, 'Updated: ', avgPostingFrequency); return (settings.dateOrder === 1) ? updatedPart + publishedPart : publishedPart + updatedPart; }, _getFavsFollowsString: function(favsCount, followsCount) { return ' - ♡' + utils.getReadableNumber(favsCount) + ' ' + '☆' + utils.getReadableNumber(followsCount) + ''; }, _getRatingTagString: function(rating) { var description = 'Unknown rating'; switch(rating.toLowerCase()) { case 'k': description = 'Intended for general audience 5 years and older. Content should be free of any coarse language, violence, and adult themes.'; break; case 'k+': description = 'Suitable for more mature childen, 9 years and older, with minor action violence without serious injury. May contain mild coarse language. Should not contain any adult themes.'; break; case 't': description = 'Suitable for teens, 13 years and older, with some violence, minor coarse language, and minor suggestive adult themes.'; break; case 'm': description = 'Not suitable for children or teens below the age of 16 with non-explicit suggestive adult themes, references to some violence, or coarse language.\n\n' + 'Fiction M can contain adult language, themes and suggestions. Detailed descriptions of physical interaction of sexual or violent nature is considered Fiction MA.'; break; case 'ma': description = 'Content is only suitable for mature adults. May contain explicit language and adult themes.'; } return 'Rated: ' + rating + ''; } }, /** * Redirects the browser to the specified chapter's page. * @param chapterNum The chapter to navigate to. */ redirectToChapter: function(chapterNum) { utils.infoBar.setText('-- Jumping to chapter ' + chapterNum + ': ' + utils.chapters.getTitle(chapterNum) + ' --'); document.location = utils.chapters.getLink(chapterNum); }, autoLoad: { /** Starts the process of loading all chapters of a story (using a recursive function). */ loadFullStory: function() { this._fullStoryLoadStep(1); }, /** * Loads a chapter with the intention of loading the next one after it (until all chapters are loaded). * @param chapterNum The chapter to load. */ _fullStoryLoadStep: function(chapterNum) { var chapNum = chapterNum; // if we're not at the end, be prepared to load the next chapter after loading this one. var callback = (chapNum < env.totalChapters) ? function() { features.autoLoad._fullStoryLoadStep(chapNum + 1); } : null; if ($('#GEASEMONKEYSEPERATOR' + chapNum).length === 0) { // the chapter hasn't been loaded yet features._loadChapterContent(chapNum, /* addBefore: */ chapNum < env.currentChapter, callback); } else { if (callback) { callback(); } } }, autoLoadLists: function( ) { env.nextListPageUrl = this._getNextLinkPageUrl($(document.body)); if (!env.nextListPageUrl) { return; } // if we don't have a "next" link, there's no place to load from. var interval = -1; interval = setInterval(function() { // stop this if we don't have a next page url, or we'll try to load from an empty string (bad but funny - it then loads this page which leads to circular loading) if (!env.nextListPageUrl) { clearInterval(interval); return; } if (utils.pos.getScreenfullsLeft() < 0.5 && !utils.infoBar.isShown()) { features.autoLoad._loadListPage(env.nextListPageUrl); } }, 100); }, _loadListPage: function(pageUrl) { utils.infoBar.setText('-- Loading next page --'); var nextLinkFunc = this._getNextLinkPageUrl; utils.httpRequest({ url: pageUrl, onload: function(responseDetails) { var responseBody = responseDetails.responseText.match(//gi); if (!responseBody || !responseBody[0]) { // the body was not found :( utils.infoBar.setText('-- Error loading page --'); return; } responseBody = responseBody[0]; responseBody = responseBody.replace(/^$/, '/div>'); responseBody = $(responseBody).first(); env.nextListPageUrl = nextLinkFunc(responseBody); responseBody.find('.z-list') .filter(function() { return features.formatting.doListEntry(this); }) // format the entry's text, and include only the non-hidden entries .hover( function() { $(this).addClass('z-high'); }, function() { $(this).removeClass('z-high'); }) // add the z-high class at hover .insertAfter('.z-list:last'); responseBody.empty().remove(); // clean up the loaded DOM. utils.infoBar.hide(); } }); }, /** * Finds the target of the "next" link of the list. * @param parent The element that should contain the list (dom or textual) */ _getNextLinkPageUrl : function(parent) { // finds links with their text ~exactly~ "next" or "Next \u00bb" (») or "Next »" var anchors = $(parent).find('a:contains("next"),a:contains("Next \u00bb"),a:contains("Next »")') .filter(function() {return $(this).text() === 'Next \u00bb' || $(this).text() === 'next'; } ); return anchors.last().attr('href'); } }, setLoadAsYouGo: function() { if (!settings.loadAsYouGo) { return; } env.lastLoadedChapter = env.currentChapter; var chapterLoadInterval = 0, refreshHashInterval = 0; chapterLoadInterval = setInterval(loadChapterIfNeeded, 100); refreshHashInterval = setInterval(refreshHash, 50); function loadChapterIfNeeded() { if (env.lastLoadedChapter >= env.totalChapters) { // stop when you've loaded all chapters. clearInterval(chapterLoadInterval); chapterLoadInterval = -1; } if (env.lastLoadedChapter < env.totalChapters && !utils.infoBar.isShown() && utils.pos.getScreenfullsLeft() < 0.5) { features._loadChapterContent(env.lastLoadedChapter + 1, false /* do not add before */, function() { env.lastLoadedChapter++; }); } } function refreshHash() { var currentHash = utils.parseNum(utils.getLocationHash()) || 0; if (currentHash >= (utils.parseNum(env.totalChapters) || 0)) { /* If there won't be anymore chapters loaded, we can stop checking for hashes. * Otherwise, a new chapter might raise the totalChapters count and more hash refreshing will be needed. */ if (chapterLoadInterval === -1) { clearInterval(refreshHashInterval); refreshHashInterval = -1; } return; } $('header.greasemonkey-chapter-separator:not(.current-chapter)').each(function() { var chapId = utils.parseNum($(this).attr('data-chapterid')) || 0; if (chapId <= currentHash) { return; } // wev'e already passed this separator. if (utils.pos.getRelativeHeight(this) < 100) { // the separator is either in the top 100 pixels of the screen, or we've passed it. document.location.hash = chapId; } }); } }, /** * Retrieves a chapter from the server asynchronously, parses it's contents, * and inserts the chapter text before or after the current chapter's one. * Note: This function also updates the environment's totalChapters with fresh info from the server. * @param chapterNum The number of the chapter to load. * @param addBefore Whether to add this chapter before the current one, or append it to the end. * @param callback An optional function to call after the loading is complete. */ _loadChapterContent: function(chapterNum, addBefore, callback) { var chapterTitle = utils.chapters.getTitle(chapterNum); utils.infoBar.setText('-- Loading chapter ' + chapterNum + '/' + env.totalChapters + ': ' + chapterTitle + ' --'); utils.httpRequest({ url: utils.chapters.getLink(chapterNum), onload: function(responseDetails) { var regmatch = responseDetails.responseText.match(/
([\s\S]*?)<\/div>/i); if (regmatch == null || !regmatch[1]) { utils.infoBar.setText('Error loading next page'); return; } // calculating new maximum chapter, in case another chapter was added. // this is not necessary when loading full stories, since the chances for an update during load are slim, and this is a somewhat costly operation if (!settings.fullStoryLoad) { env.totalChapters = utils.chapters.getCountFromHtmlString(responseDetails.responseText); } var storytext = regmatch[1]; var sharetext = ''; // if we have a "Share" div, we remove it from the story text so we can put it /before/ the seperator. var sharematch = storytext.match(/
Fanfiction Tools Options'); $('#menu-fftools').click(features.optionsMenu.show); GM_addStyle( '#ffto-mask { top: 0; left: 0; width: 100%; height: 100%; position: fixed; opacity: 0.75; background-color: #777; }' + '#ffto-menu-wrapper { border: 1px solid #CDCDCD; background-color: #F6F7EE; padding: 4px; width: 500px;' + 'position: absolute; top: 50px; left: 50%; margin-left: -250px; }' + '#ffto-menu { background-color: white; min-height: 150px; border: 1px solid #CDCDCD; }' + '#ffto-menu-title { color: white; background-color: #339; font-weight: bold; font-size: 15px; padding: 5px 10px; margin-bottom: 6px; }' + '#fftools-options-body { font-size: 12px; padding: 5px; }' + '.ffto-title { letter-spacing: 1px; margin-left: 3px; font-size: 16px; margin-top: 2px; margin-bottom: 2px; }' + '.ffto-sect { padding: 5px 2px; margin: 5px 0; border-top: 1px solid #CDCDCD; border-bottom: 1px solid #CDCDCD }' + '.ffto-sect:last-of-type { border-bottom: none }' + '#ffto-menu UL { padding: 0; margin: 0; list-style: none; }' + '#ffto-menu SELECT { font-size: 12px; margin: 0 5px; }' + '#ffto-menu INPUT[type=text] { font-size: 12px; padding: 2px; margin: 0 5px; }' + '#ffto-menu .comment { font-size: 9px; display: inline-block; }' + '#ffto-menu .help { float: right; border: 1px solid #339; border-radius: 10px; height: 18px; width: 18px; text-align: center; cursor: help; }' + '#ffto-sect-dates > UL { margin-bottom: 5px; }' + '#ffto-sect-dates > UL > LI { display: inline-block; width: 49%; }' + '#ffto-sect-dates > DIV { display: inline-block; margin: 0 5px; margin-top: 5px; }' + '#ffto-date-sep { text-align: center; }' + '#ffto-autoload-stories, #ffto-autoload-lists { margin-left: 5px; }' + '#ffto-sect-info LI { margin: 3px 0 }' + '#ffto-menu-footer { font-size: 10px; margin-top: 2px; position: relative }' + '#ffto-link-to-script { display: inline-block; right: 0pt; position: absolute; bottom: 0 }' + '#ffto-buttons { text-align: center; }' + '#ffto-buttons INPUT[type=button] { margin: 3px }' + '#ffto-menu-close-x { float: right; color: white; border: medium none; font-weight: inherit; }'); $(env.w.document.body).append( '' + ''); $('#ffto-menu-close-x').click(features.optionsMenu.hide); $('#ffto-cancel-button').click(features.optionsMenu.hide); $('#ffto-save-button').click(features.optionsMenu._setChanges); }, /** Opens up the menu. */ show: function() { $('#ffto-mask').show(); $('#ffto-menu-wrapper').show(); }, /** Hides the menu. */ hide: function() { $('#ffto-mask').hide(); $('#ffto-menu-wrapper').hide(); }, /** * Update the script's settings with the selected values in the menu. * Refreshes the page afterwards to apply the changes. */ _setChanges: function() { settings.colorDate = $('#ffto-color-dates')[0].checked; settings.colorComplete = $('#ffto-color-complete')[0].checked; settings.dateFormat = utils.parseNum($('#ffto-date-format')[0].value) || 0; settings.dateOrder = utils.parseNum($('#ffto-dates-order')[0].value) || 0; settings.sep = $('#ffto-date-sep')[0].value; settings.fullStoryLoad = ($('#ffto-autoload-stories')[0].value === 'full'); settings.loadAsYouGo = ($('#ffto-autoload-stories')[0].value === 'chapter'); settings.loadListsAsYouGo = $('#ffto-autoload-lists')[0].checked; settings.markWords = $('#ffto-marked-words')[0].value.split('|'); settings.combineReview = $('#ffto-combine-reviews-link')[0].checked; settings.shouldMenusAutoClose = utils.parseNum($('#ffto-autoclose-menus')[0].value); settings.shouldRelativeDate = $('#ffto-relative-dates')[0].checked; settings.showPostingFrequency = $('#ffto-posting-frequency')[0].checked; settings.hideChaptersNavigator = $('#ffto-hide-chapters-navigator')[0].checked; settings.viewLanguages = $('#ffto-view-langs')[0].value.split('|'); settings.shortenFavsFollows = $('#ffto-shorten-favs-follows')[0].checked; // we use a timeout to ensure that we we don't set the settings from a 3rd party script (and therefore at risk). // actually GreaseMonkey throws an exception if we don't ensure that :) setTimeout(function() { features.settings.save(); location.reload(); features.optionsMenu.hide(); }, 0); } }, /***************** Separators *****************/ /** * Returns an HTML string of a chapter-separator element. * @param chapterNum The number of the chapter (to identify the separator). * @param chapterTitle The optional title of the chapter to view in the separator text. If not given, it fetches the chapter's title itself :) * @return An HTML string. */ _getChapterSeparator: function(chapterNum, chapterTitle) { if (chapterTitle == null) { chapterTitle = utils.chapters.getTitle(chapterNum); } return '
' + chapterNum + '/' + env.totalChapters + ': ' + chapterTitle + '
'; }, /** * Adds an "End of story" remark/separator at the end of the story, if the given chapter is the last one. * @param chapterNum The number of chapter being inserted. */ _addEndOfStorySeperatorIfNeeded: function(chapterNum) { if (chapterNum >= env.totalChapters && $('#GEASEMONKEYSEPERATOR_END').length === 0) { $('#story-end').after('
End of story
'); } } }; /***************** Utils *****************/ utils = { /** The date right now. */ now: new Date(), /** Returns the hash part of the location */ getLocationHash: function() { return document.location.hash.substr(1); }, parseNum: function(num) { if (typeof(num) === "number") { return num; } if (typeof(num) === "string") { return Number(num.trim().replace(/,/g, '')); } return Number(num); }, /** * Adds commas after every 3 digits in the number. * @param num The number to format */ getReadableNumber: function(num) { var str = (num+"").split("."), // stringify the number and split it by dots. full = str[0].replace(/(\d)(?=(\d{3})+\b)/g,"$1,"), // adding commas to the part before the dot dec = str[1] || ""; // getting the part after the dot, if exists return (dec) ? full + '.' + dec : full; }, getLocation: function() { var canonical = $('link[rel="canonical"]'); var url = canonical.length > 0 ? canonical[0].href : document.location(); return (url || '').replace('http://fanfiction.net/', 'http://www.fanfiction.net/'); }, /** * Performs an AJAX request * @param configObj Object an object containing request the information: * @cfg object headers A dictionary of headers to be sent with the request. * @cfg string method The type of method to use in the request. Defaults to GET. * @cfg string url The url to request. * @cfg Function onload The callback to call when the request is done. Passed the response object. */ httpRequest: function (configObj) { if (!configObj.headers) { configObj.headers = {}; } configObj.headers['User-Agent'] = 'Mozilla/4.0 (compatible) Greasemonkey'; var req = new XMLHttpRequest(); req.open(configObj.method || 'GET', configObj.url, true); req.onreadystatechange = function () { if (req.readyState === 4) { configObj.onload(req); } }; req.send(null); }, chapters: { /** Returns the current chapter by the page's url. It doesn't use the navigator because there isn't one in single-chapter stories. */ getCurrent: function() { // http://www.fanfiction.net/s/6261249/2/Konoha_At_His_Fingertips var loc = /(.*\/s\/\d+\/)(\d+)(\/[^#]*)?/i.exec(utils.getLocation()); return (loc && loc[2]) ? utils.parseNum(loc[2]) : 1; }, /** * Formats a link to a specific chapter. * @param chapterNum The number of the chapter to link to. */ getLink: function(chapterNum) { var loc = /(.*\/s\/\d+\/)(\d+)(\/[^#]*)?/i.exec(utils.getLocation()); return loc[1] + chapterNum + (loc[3] || ''); }, /** * Returns the title of a chapter. * @param chapterNum The number of the chapter to link to. */ getTitle: function(chapterNum) { var navigator = utils.getChapterNavigator(); if (navigator.length < 1) { return 'Unknown title. No navigation combo-box found'; } var children = $(navigator[0]).children('option[value="' + chapterNum + '"]'); if (children.length < 1) { return 'Unknown title. Chapter not found in navigation combo-box.'; } // strip the chapter number from the option text and return it. return children[0].text.replace(new RegExp('^' + chapterNum + '\\.\\s*'), ''); }, /** * Returns the id of the last chapter of the story (by returning the last option in this page's chapterNavigator. * (via getChapterNavigator()) */ getCount: function() { var lastChapterEl = utils.getChapterNavigator().first().children('option:last-child'); if (lastChapterEl.length > 0) { return lastChapterEl[0].value; } }, /** * Parse the string to find the navigator and the max chapter. * If nothing is found, the last option in this page's chapterNavigator is returned. (via getChapterNavigator()) * @param htmlString The HTML text to parse for a chapter navigator */ getCountFromHtmlString: function(htmlString) { var chapterOptions = htmlString.match(/