├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── _injection_list.ts ├── background.ts ├── base.ts ├── common.ts ├── common_fa.ts ├── downloader.ts ├── highlighter.ts ├── hotkeys.ts ├── loaderclasses.ts ├── logger.ts ├── open_in_tabs.ts ├── options.ts ├── page_inject.ts ├── story_in_gdocs.ts └── tabdelay.ts ├── docs ├── icon.png └── index.html ├── manifest ├── chrome.json ├── firefox.json └── manifest.json ├── package-lock.json ├── package.json ├── src ├── icon128.png ├── icon32.png ├── icon48.png ├── options.html └── tabdelay.html ├── tsconfig.json └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "ecmaFeatures": { 7 | "experimentalObjectRestSpread": true 8 | }, 9 | "project": "./tsconfig.json" 10 | }, 11 | "env": { 12 | "es6": true, 13 | "browser": true, 14 | "webextensions": true, 15 | "jquery": true, 16 | "node": true 17 | }, 18 | "extends": [ 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:@typescript-eslint/eslint-recommended" 21 | ], 22 | "rules": { 23 | "no-console": "error", 24 | "object-curly-spacing": ["warn", "always"], 25 | "one-var": ["error", "never"], 26 | "quote-props": "warn", 27 | "require-jsdoc": "off", 28 | "valid-jsdoc": "off", 29 | "@typescript-eslint/explicit-function-return-type": "off", 30 | "@typescript-eslint/explicit-member-accessibility": "off", 31 | "@typescript-eslint/member-ordering": "warn", 32 | "@typescript-eslint/no-parameter-properties": "off", 33 | "@typescript-eslint/no-use-before-define": "off", 34 | "space-before-function-paren": "off" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: kaukocheetah 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | custom: # Replace with a single custom sponsorship URL 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, firefox] 29 | - Version [e.g. 22] 30 | - Extension versiono [e.g. 1.2.2] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: Feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | *.crx 3 | node_modules/** 4 | bower_components/** 5 | build/** 6 | gulp.lnk 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact" 7 | ] 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kauko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## FurAffinity Extender 2 | 3 | FurAffinity Extender adds features to enhance your browsing experience of FurAffinity for both Chrome and Firefox. 4 | 5 | #### Features: 6 | * Easy downloading of files 7 | * Highlights submissions and journals 8 | * Adds hotkeys 9 | * Open all images in tabs 10 | * Open stories in Google Docs previewer 11 | 12 | ### Technology 13 | * WebExtensions 14 | * Typescript 15 | * Webpack 16 | 17 | ### Building 18 | * Install node and npm 19 | * Run `npm install` 20 | * Run `npm run build` 21 | 22 | Extension zip files will be in `build/`. 23 | -------------------------------------------------------------------------------- /app/_injection_list.ts: -------------------------------------------------------------------------------- 1 | import { getSiteVersion } from "./common_fa"; 2 | 3 | type InjectionPointPath = { 4 | [P in keyof T]: string; 5 | }; 6 | 7 | interface InjectionVersions { 8 | classic: InjectionPointPath; 9 | beta: InjectionPointPath; 10 | } 11 | 12 | interface InjectionPoints { 13 | downloadInsertPosition: HTMLDivElement; 14 | downloadLink: HTMLAnchorElement; 15 | artistLink: HTMLAnchorElement; 16 | addFavoriteLink: HTMLAnchorElement; 17 | miniGallery: HTMLElement; 18 | journalHighlightMatch: HTMLLIElement; 19 | submissionHighlightMatch: HTMLAnchorElement; 20 | standardSubmissionLink: HTMLAnchorElement; 21 | insertInTabsInsertPositionSubmissions: HTMLDivElement; 22 | insertInTabsInsertPositionGallery: HTMLDivElement; 23 | insertInTabsInsertPositionFavorites: HTMLDivElement; 24 | openInGDocsInsertPosition: HTMLElement; 25 | } 26 | 27 | const InjectionPointList: InjectionVersions = { 28 | "classic": { 29 | "downloadInsertPosition": "#page-submission .maintable:first th.cat", 30 | "downloadLink": "#page-submission div.actions a:contains('Download')", 31 | "artistLink": "#page-submission table.maintable td.cat div.information a[href*='/user/']", 32 | "addFavoriteLink": "a[href^='/fav/']:contains('+Add to Favorites')", 33 | "miniGallery": "#page-submission div.minigalleries", 34 | "journalHighlightMatch": "#messages-journals ul.message-stream li", 35 | "submissionHighlightMatch": "#messagecenter-submissions section.gallery figure", 36 | "standardSubmissionLink": "figure figcaption a[href*='/view/']", 37 | "insertInTabsInsertPositionSubmissions": "#messagecenter-submissions div.actions", 38 | "insertInTabsInsertPositionGallery": "#page-galleryscraps div.page-options", 39 | "insertInTabsInsertPositionFavorites": "#favorites table.maintable>tbody>tr>td>table>tbody>tr:nth-child(1)>td:nth-child(1)", 40 | "openInGDocsInsertPosition": "#page-submission div.actions a:contains('Download')" 41 | }, 42 | "beta": { 43 | "downloadInsertPosition": "#submission_page div.submission-sidebar", 44 | "downloadLink": "#submission_page section.buttons div.download a:contains('Download')", 45 | "artistLink": "#submission_page div.submission-content div.submission-id-sub-container a[href*='/user/']", 46 | "addFavoriteLink": "a[href^='/fav/']:contains('+ Fav')", 47 | "miniGallery": "#submission_page section.minigallery-more div.preview-gallery", 48 | "journalHighlightMatch": "#messagecenter-other div#messages-journals ul.message-stream li", 49 | "submissionHighlightMatch": "#messagecenter-submissions section.gallery figure", 50 | "standardSubmissionLink": "figure figcaption a[href*='/view/']", 51 | "insertInTabsInsertPositionSubmissions": "#messagecenter-new-submissions div.section-body>div", 52 | "insertInTabsInsertPositionGallery": "#page-galleryscraps div.submission-list div.aligncenter", 53 | "insertInTabsInsertPositionFavorites": "#standardpage div.aligncenter", 54 | "openInGDocsInsertPosition": "#submission_page section.buttons" 55 | } 56 | }; 57 | 58 | export function getInjectionPoint(point: keyof InjectionPoints): string { 59 | const ver = getSiteVersion(); 60 | return InjectionPointList[ver][point]; 61 | } 62 | 63 | export function getInjectionElement(point: T): JQuery { 64 | const target = getInjectionPoint(point); 65 | return jQuery(target); 66 | } 67 | -------------------------------------------------------------------------------- /app/background.ts: -------------------------------------------------------------------------------- 1 | /* FAExtender background code */ 2 | 3 | import browser from "webextension-polyfill"; 4 | import { Logger } from "./logger"; 5 | import { MessageActions, MessageType } from "./common"; 6 | 7 | browser.runtime.onMessage.addListener(async (request: MessageType) => { 8 | if (request.action === MessageActions.downloader.save) { 9 | let conflictAction: import("webextension-polyfill").Downloads.FilenameConflictAction = "prompt"; 10 | let manifest = browser.runtime.getManifest(); 11 | if (manifest.browser_specific_settings?.gecko) { 12 | // Prompt isn't supported on Firefox 13 | conflictAction = "overwrite"; 14 | } 15 | 16 | try { 17 | await browser.downloads.download({ 18 | "url": request.options.url, 19 | "filename": request.options.filename, 20 | "saveAs": false, 21 | conflictAction 22 | }); 23 | return { "message": "Download complete" }; 24 | } catch (err) { 25 | Logger.error("Got download error", err); 26 | return { "message": "Error downloading", "err": err.message }; 27 | } 28 | } else if (request.action === MessageActions.downloader.exists) { 29 | try { 30 | const downloads = await browser.downloads.search({ "url": request.options }); 31 | let exists = false; 32 | if (downloads && downloads.length > 0) { 33 | for (const download of downloads) { 34 | if (download.exists) { 35 | exists = true; 36 | break; 37 | } 38 | } 39 | } 40 | 41 | return exists; 42 | } catch (err) { 43 | Logger.error("Got download search error", err); 44 | return false; 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /app/base.ts: -------------------------------------------------------------------------------- 1 | /* FAExtender base */ 2 | 3 | import { Logger } from "./logger"; 4 | import { StandardLoader } from "./loaderclasses"; 5 | 6 | /** 7 | * Base injector control 8 | */ 9 | export class Base { 10 | private targets: { callback: () => StandardLoader; locations: string[] }[] = []; 11 | 12 | /** 13 | * Determine if a page matches the location 14 | * @param allowed Allowed paths 15 | * @return Returns true if location matches, false if not 16 | */ 17 | checkLocation(allowed: string[]): boolean { 18 | const loc = document.location; 19 | if (!loc) return false; 20 | 21 | // Check each allowed path 22 | for (let i = 0; i < allowed.length; i++) { 23 | const path = allowed[i]; 24 | if (loc.pathname.indexOf(path) === 0) return true; 25 | } 26 | 27 | return false; 28 | } 29 | 30 | /** 31 | * Register a function to call on page load 32 | * @param callback Callback to execute 33 | * @param locations Locations to test against 34 | */ 35 | registerTarget(callback: () => StandardLoader, locations: string[]): void { 36 | if (!callback) { 37 | Logger.error("Callback registered was null"); 38 | return; 39 | } 40 | 41 | this.targets.push({ "callback": callback, "locations": locations }); 42 | } 43 | 44 | /** 45 | * Fired when an individual page/tab loads 46 | */ 47 | onPageLoad(): void { 48 | this.targets.forEach((target) => { 49 | if (this.checkLocation(target.locations)) { 50 | target.callback().bind().catch((err) => { 51 | Logger.error(err); 52 | }); 53 | } 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/common.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | 3 | const StrictlySettings = < 4 | T extends { [k: string]: R }, 5 | R extends keyof SettingsKeyTypes, 6 | Y 7 | >( 8 | obj: T, 9 | x?: Y, 10 | ) => (x ? Object.assign(obj, x) : obj) as T & Y; 11 | 12 | export interface SettingsKeyTypes { 13 | openintabs_nodelay: boolean; 14 | openintabs_unreverse: boolean; 15 | openintabs_delaytime: number; 16 | hotkeys_enabled: boolean; 17 | highlighter_keys: HighlighterKey[]; 18 | save_folder: string; 19 | save_subdirs: boolean; 20 | } 21 | 22 | export type SettingKeyKeys = keyof SettingsKeyTypes; 23 | 24 | export const SettingsKeys = { 25 | "openintabs": StrictlySettings({ 26 | "no_delay": "openintabs_nodelay", 27 | "unreverse": "openintabs_unreverse", 28 | "delay_time": "openintabs_delaytime" 29 | }), 30 | "hotkeys": StrictlySettings({ 31 | "enabled": "hotkeys_enabled" 32 | }), 33 | "highlighter": StrictlySettings({ 34 | "keys": "highlighter_keys" 35 | }), 36 | "downloader": StrictlySettings({ 37 | "subfolder": "save_folder", 38 | "artist_subdirs": "save_subdirs" 39 | }) 40 | }; 41 | 42 | // Message actions 43 | 44 | const StrictlyMessages = < 45 | T extends { [k: string]: R }, 46 | R extends MessageTypes, 47 | Y 48 | >( 49 | obj: T, 50 | x?: Y, 51 | ) => (x ? Object.assign(obj, x) : obj) as T & Y; 52 | 53 | export type MessageTypes = "save_file" | "find_download_exists"; 54 | 55 | export const MessageActions = { 56 | "downloader": StrictlyMessages({ 57 | "save": "save_file", 58 | "exists": "find_download_exists" 59 | }) 60 | }; 61 | 62 | export interface MessageTypeDownloaderSave { 63 | action: "save_file"; 64 | options: { 65 | url: string; 66 | filename: string; 67 | }; 68 | } 69 | 70 | export interface MessageTypeDownloaderExists { 71 | action: "find_download_exists"; 72 | options: string; 73 | } 74 | 75 | export type MessageType = MessageTypeDownloaderSave | MessageTypeDownloaderExists; 76 | 77 | // Highlight types 78 | 79 | export type HighlightTypes = "submission" | "journal"; 80 | export type HightlightFields = "title" | "user"; 81 | export interface HighlighterKey { 82 | type: HighlightTypes; 83 | field: HightlightFields; 84 | text: string; 85 | color: string; 86 | } 87 | -------------------------------------------------------------------------------- /app/common_fa.ts: -------------------------------------------------------------------------------- 1 | import jQuery from "jquery"; 2 | 3 | export function getSiteVersion(): "classic" | "beta" { 4 | const staticPath = jQuery("body").data("static-path"); 5 | if (staticPath === "/themes/beta") { 6 | return "beta"; 7 | } 8 | 9 | return "classic"; 10 | } 11 | 12 | export function getSubmissionId(pathname: string = window.location.pathname): number { 13 | return parseInt(pathname.split("/")[2]); 14 | } 15 | -------------------------------------------------------------------------------- /app/downloader.ts: -------------------------------------------------------------------------------- 1 | /* Download support */ 2 | 3 | import browser from "webextension-polyfill"; 4 | import { StorageLoader } from "./loaderclasses"; 5 | import { Logger } from "./logger"; 6 | import { SettingsKeys, MessageActions, MessageTypeDownloaderExists, SettingsKeyTypes, MessageTypeDownloaderSave } from "./common"; 7 | import { getInjectionElement } from "./_injection_list"; 8 | 9 | interface Components { 10 | "url": string; 11 | "path": string; 12 | "artist": string; 13 | "pretty_artist": string; 14 | "filename": string; 15 | "extension": string; 16 | } 17 | 18 | class Downloader extends StorageLoader { 19 | constructor() { 20 | super(SettingsKeys.downloader.subfolder, SettingsKeys.downloader.artist_subdirs); 21 | } 22 | 23 | private static save(options: SettingsKeyTypes, components: Components, downloadSpan: JQuery) { 24 | const fname = components.filename; 25 | const url = components.url; 26 | 27 | let dir = options[SettingsKeys.downloader.subfolder] || ""; 28 | if (dir.trim() !== "") { 29 | dir = dir + "/"; 30 | } 31 | 32 | // Pretty artist name support 33 | const artist = components.artist; 34 | /* if (options.download_prettyartist) { 35 | artist = components.pretty_artist; 36 | }*/ 37 | 38 | if (options[SettingsKeys.downloader.artist_subdirs]) { 39 | dir = `${dir}${artist}/`; 40 | } 41 | 42 | const filename = `${dir}${fname}`; 43 | 44 | const msg: MessageTypeDownloaderSave = { "action": MessageActions.downloader.save, "options": { url, filename } }; 45 | browser.runtime.sendMessage(msg).then((response) => { 46 | downloadSpan.text(response.message); 47 | 48 | if (response.err) { 49 | downloadSpan.attr("title", response.err); 50 | } 51 | }).catch((err) => { 52 | downloadSpan.text("Error").attr("title", err.message); 53 | Logger.error("Got background communication error", err); 54 | }); 55 | 56 | downloadSpan.text("Download started"); 57 | } 58 | 59 | async init() { 60 | // Get image URL 61 | const components = this.getDownloadUrlComponents(); 62 | if (!components) return; 63 | 64 | // Set up ID links 65 | let downloadLink = jQuery("#__ext_fa_imgdl"); 66 | let downloadSpan = jQuery("#__ext_fa_imgdlsp"); 67 | 68 | // Check to make sure we haven't already injected 69 | if ((downloadLink.length > 0) || (downloadSpan.length > 0)) { 70 | return; 71 | } 72 | 73 | // Find our download text injection point 74 | const downloadInsertPos = getInjectionElement("downloadInsertPosition"); 75 | if (downloadInsertPos.length === 0) { 76 | // Can't find either 77 | Logger.error("Bad download inject selector, aborting"); 78 | return; 79 | } 80 | 81 | // Inject text 82 | downloadLink = jQuery("").attr("href", "javascript:void(0);").attr("id", "__ext_fa_imgdl").text("Download now"); 83 | downloadSpan = jQuery("").attr("id", "__ext_fa_imgdlsp").append(downloadLink); 84 | downloadInsertPos.prepend(jQuery("").append("[").append(downloadSpan).append("]")); 85 | 86 | const chgMsg = (text: string, alt?: string) => { 87 | downloadSpan.html(text); 88 | downloadSpan.attr("title", alt); 89 | }; 90 | 91 | if (!components.extension) { 92 | chgMsg("Error: No extension", "This file does not have an file extension. Please save it manually."); 93 | return; 94 | } 95 | 96 | const configureDownload = () => { 97 | // Handle link onclick event 98 | downloadLink.on("click", () => { 99 | Downloader.save(this.options, components, downloadSpan); 100 | }); 101 | }; 102 | 103 | // Check if the download exists 104 | try { 105 | const msg: MessageTypeDownloaderExists = { "action": MessageActions.downloader.exists, "options": components.url }; 106 | const exists = await browser.runtime.sendMessage(msg); 107 | if (exists === true) { 108 | chgMsg("File already exists."); 109 | return; 110 | } 111 | } finally { 112 | configureDownload(); 113 | } 114 | } 115 | 116 | private getDownloadLink() { 117 | const downloadLink = getInjectionElement("downloadLink"); 118 | if (downloadLink.length === 0) { 119 | // No download at all 120 | Logger.error("Could not find download link"); 121 | return null; 122 | } 123 | 124 | return downloadLink; 125 | } 126 | 127 | private getArtistLink() { 128 | const artistLink = getInjectionElement("artistLink"); 129 | if (artistLink.length === 0) { 130 | // Can't find artist link 131 | Logger.error("Could not find artist selector"); 132 | return null; 133 | } 134 | 135 | return artistLink; 136 | } 137 | 138 | // Handle retrieving download URL 139 | private getDownloadUrlComponents(): Components { 140 | const downloadLink = this.getDownloadLink(); 141 | if (!downloadLink || downloadLink.length === 0) { 142 | return null; 143 | } 144 | 145 | const components = downloadLink[0]; 146 | 147 | const url = components.href; 148 | const path = components.pathname; 149 | 150 | if (!url || !path) { 151 | return null; 152 | } 153 | 154 | const artistLink = this.getArtistLink(); 155 | if (!artistLink) { 156 | return null; 157 | } 158 | 159 | const artistPath = artistLink.attr("href"); 160 | const artist = artistPath.replace("/user/", "").replace("/", ""); 161 | const prettyArtist = artistLink.text(); 162 | 163 | const fname = decodeURI(path.substring(path.lastIndexOf("/") + 1)); 164 | const fext = fname.substring(fname.lastIndexOf(".") + 1); 165 | 166 | return { "url": url, "path": path, "artist": artist, "pretty_artist": prettyArtist, "filename": fname, "extension": fext }; 167 | } 168 | } 169 | 170 | export default function (base: import("./base").Base) { 171 | base.registerTarget(() => new Downloader(), ["/view/", "/full/"]); 172 | } 173 | -------------------------------------------------------------------------------- /app/highlighter.ts: -------------------------------------------------------------------------------- 1 | /* Journal highlight */ 2 | 3 | import { StorageLoader } from "./loaderclasses"; 4 | import { SettingsKeys, HighlighterKey, HighlightTypes, HightlightFields } from "./common"; 5 | import { getInjectionPoint } from "./_injection_list"; 6 | 7 | interface HighlightArguments { 8 | name: HighlightTypes; 9 | match: string; 10 | tests: { 11 | [K in HightlightFields]: string 12 | }; 13 | } 14 | 15 | class Highlighter extends StorageLoader { 16 | constructor(protected args: HighlightArguments) { 17 | super(SettingsKeys.highlighter.keys); 18 | } 19 | 20 | init() { 21 | // Exit if no keys are set 22 | let keys = this.options[SettingsKeys.highlighter.keys]; 23 | if (!keys) return; 24 | keys = keys.filter((f) => f.type === this.args.name); 25 | if (!keys) return; 26 | 27 | // Iterate through each row 28 | jQuery(this.args.match).each((i, row) => { 29 | // Evaluate each test 30 | jQuery.each(this.args.tests, (field, match) => { 31 | // Perform the test 32 | return this.evaluateRow(row, keys, field, match); 33 | }); 34 | }); 35 | } 36 | 37 | evaluateRow(row: HTMLElement, list: HighlighterKey[], type: HightlightFields, match: string) { 38 | // Get match target text 39 | const target = jQuery(match, row).text().toLowerCase(); 40 | 41 | // Iterate through each list element 42 | for (let i = 0; i < list.length; i++) { 43 | const item = list[i]; 44 | 45 | // Test for match 46 | if (target.indexOf(item.text.toLowerCase()) > -1) { 47 | // Set background color 48 | jQuery(row).css("background-color", item.color); 49 | return false; 50 | } 51 | } 52 | 53 | // No matches 54 | return true; 55 | } 56 | } 57 | 58 | class JournalHighlighter extends Highlighter { 59 | constructor() { 60 | super({ 61 | "name": "journal", 62 | "match": getInjectionPoint("journalHighlightMatch"), 63 | "tests": { 64 | "user": "a[href^='/user/']", 65 | "title": "a[href^='/journal/']" 66 | } 67 | }); 68 | } 69 | } 70 | 71 | class SubmissionHighlighter extends Highlighter { 72 | constructor() { 73 | super({ 74 | "name": "submission", 75 | "match": getInjectionPoint("submissionHighlightMatch"), 76 | "tests": { 77 | "user": "a[href^='/user/']", 78 | "title": "a[href^='/view/']" 79 | } 80 | }); 81 | } 82 | } 83 | 84 | export default function (base: import("./base").Base) { 85 | base.registerTarget(() => new JournalHighlighter(), ["/msg/others/"]); 86 | base.registerTarget(() => new SubmissionHighlighter(), ["/msg/submissions/"]); 87 | } 88 | -------------------------------------------------------------------------------- /app/hotkeys.ts: -------------------------------------------------------------------------------- 1 | /* Hotkey support */ 2 | 3 | import "jquery.hotkeys"; 4 | 5 | import { StorageLoader } from "./loaderclasses"; 6 | import { SettingsKeys } from "./common"; 7 | import { getSubmissionId, getSiteVersion } from "./common_fa"; 8 | import { getInjectionPoint, getInjectionElement } from "./_injection_list"; 9 | 10 | 11 | class Hotkeys extends StorageLoader { 12 | constructor() { 13 | super(SettingsKeys.hotkeys.enabled); 14 | } 15 | 16 | init() { 17 | if (!this.options[SettingsKeys.hotkeys.enabled]) return; 18 | 19 | // Collect prev/next links 20 | let prevLink: JQuery; 21 | let nextLink: JQuery; 22 | 23 | if (getSiteVersion() === "beta") { 24 | // New layout is missing focal point, so figure it out by submission ID 25 | const miniTarget = jQuery(getInjectionPoint("miniGallery") + " a"); 26 | if (miniTarget.length > 0) { 27 | const currentId = getSubmissionId(); 28 | const allIds = miniTarget.toArray().map((d) => { 29 | const href = jQuery(d).attr("href"); 30 | return getSubmissionId(href); 31 | }).sort(); 32 | 33 | let prevId = -1; 34 | let nextId = -1; 35 | for (let index = 0; index < allIds.length; index++) { 36 | const curId = allIds[index]; 37 | if (curId > currentId) { 38 | nextId = curId; 39 | break; 40 | } else { 41 | prevId = curId; 42 | } 43 | } 44 | 45 | if (prevId > -1) { 46 | prevLink = miniTarget.parent().find(`a[href*='${prevId}']`) as JQuery; 47 | } 48 | if (nextId > -1) { 49 | nextLink = miniTarget.parent().find(`a[href*='${nextId}']`) as JQuery; 50 | } 51 | } 52 | } else { 53 | // Pull from the minigallery centered around the title 54 | const miniTarget = jQuery(getInjectionPoint("miniGallery") + " .minigallery-title"); 55 | if (miniTarget.length > 0) { 56 | prevLink = miniTarget.next().find("a") as JQuery; 57 | nextLink = miniTarget.prev().find("a") as JQuery; 58 | } 59 | } 60 | 61 | // Previous link 62 | let prevHref: string; 63 | if (prevLink && prevLink.length > 0) { 64 | prevHref = prevLink[0].href; 65 | } 66 | 67 | const prevClick = () => { 68 | if (prevHref) document.location.href = prevHref; 69 | }; 70 | 71 | jQuery(document).on("keydown", null, "left", prevClick); 72 | jQuery(document).on("keydown", null, "p", prevClick); 73 | 74 | // Next link 75 | let nextHref: string; 76 | if (nextLink && nextLink.length > 0) { 77 | nextHref = nextLink[0].href; 78 | } 79 | 80 | const nextClick = () => { 81 | if (nextHref) document.location.href = nextHref; 82 | }; 83 | 84 | jQuery(document).on("keydown", null, "right", nextClick); 85 | jQuery(document).on("keydown", null, "n", nextClick); 86 | 87 | // Favorite 88 | jQuery(document).on("keydown", null, "f", function () { 89 | const href = getInjectionElement("addFavoriteLink").attr("href"); 90 | if (href) document.location.href = href; 91 | }); 92 | 93 | // Save 94 | jQuery(document).on("keydown", null, "s", function () { 95 | jQuery("a#__ext_fa_imgdl").trigger("click"); 96 | }); 97 | } 98 | } 99 | 100 | export default function (base: import("./base").Base) { 101 | base.registerTarget(() => new Hotkeys(), ["/view/", "/full/"]); 102 | } 103 | -------------------------------------------------------------------------------- /app/loaderclasses.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import { SettingKeyKeys, SettingsKeyTypes } from "./common"; 3 | 4 | /** Standard injector */ 5 | export abstract class StandardLoader { 6 | /** Bind a document to the class */ 7 | async bind(): Promise { 8 | await this.init(); 9 | } 10 | 11 | /** Initialize the binding. Override this */ 12 | abstract init(): Promise | void; 13 | } 14 | 15 | 16 | /** Storage injector */ 17 | export abstract class StorageLoader extends StandardLoader { 18 | protected storageVars: SettingKeyKeys[]; 19 | protected options: SettingsKeyTypes; 20 | 21 | constructor(...storageVars: SettingKeyKeys[]) { 22 | super(); 23 | this.storageVars = storageVars; 24 | } 25 | 26 | /** Bind a document to the class */ 27 | async bind() { 28 | const obj = await browser.storage.sync.get(this.storageVars); 29 | this.options = (obj as SettingsKeyTypes); 30 | await this.init(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: "off", @typescript-eslint/no-explicit-any: "off" */ 2 | 3 | /** Logger */ 4 | class LoggerWrapper { 5 | /** Log an error message */ 6 | error(...args: any[]) { 7 | console.warn("FAExtender error", ...args); 8 | } 9 | 10 | /** Log a debug message */ 11 | debug(...args: any[]) { 12 | console.log("FAExtender debug", ...args); 13 | } 14 | } 15 | 16 | export const Logger = new LoggerWrapper(); 17 | -------------------------------------------------------------------------------- /app/open_in_tabs.ts: -------------------------------------------------------------------------------- 1 | /* Open in tabs */ 2 | 3 | import browser from "webextension-polyfill"; 4 | import { Logger } from "./logger"; 5 | import { StorageLoader } from "./loaderclasses"; 6 | import { SettingsKeys } from "./common"; 7 | import { getSiteVersion } from "./common_fa"; 8 | import { getInjectionElement, getInjectionPoint } from "./_injection_list"; 9 | 10 | 11 | class OpenInTabs extends StorageLoader { 12 | constructor() { 13 | super(SettingsKeys.openintabs.unreverse, SettingsKeys.openintabs.no_delay, SettingsKeys.openintabs.delay_time); 14 | } 15 | 16 | init() { 17 | // Collect all view page links 18 | const tabLinks = jQuery.makeArray(getInjectionElement("standardSubmissionLink")); 19 | 20 | // Exit if no valid links were found so we don't inject 21 | if (tabLinks.length === 0) return; 22 | 23 | // Flip the page link order (oldest to newest by default) 24 | if (!this.options[SettingsKeys.openintabs.unreverse]) { 25 | tabLinks.reverse(); 26 | } 27 | 28 | // Check to make sure if the injection point already exists 29 | const openLinkCheck = jQuery("#__ext_fa_opentabs"); 30 | if (openLinkCheck.length > 0) return; 31 | 32 | // Find our tabs open injection point 33 | let openLink: JQuery; 34 | if (getSiteVersion() === "beta") { 35 | openLink = this.injectBeta(); 36 | } else { 37 | openLink = this.injectClassic(); 38 | } 39 | 40 | if (!openLink) { 41 | // Couldn't inject 42 | return; 43 | } 44 | 45 | openLink.on("click", () => { 46 | // Find the links, use a delay if configured 47 | const queueTimeDelay = this.options[SettingsKeys.openintabs.delay_time] || 2; 48 | let useQueueTimer = !this.options[SettingsKeys.openintabs.no_delay]; 49 | 50 | // Start with the delay 51 | let queueTime = queueTimeDelay; 52 | 53 | if (queueTimeDelay < 1) { 54 | useQueueTimer = false; 55 | } 56 | 57 | tabLinks.forEach((thisLink) => { 58 | if (useQueueTimer) { 59 | window.open(browser.runtime.getURL("tabdelay.html") + "?url=" + encodeURI(thisLink.href) + "&delay=" + queueTime); 60 | queueTime += queueTimeDelay; 61 | } else { 62 | window.open(thisLink.href); 63 | } 64 | }); 65 | }); 66 | } 67 | 68 | injectClassic() { 69 | // Create Open in Tabs link 70 | const openLink = jQuery("") 71 | .attr("id", "__ext_fa_opentabs") 72 | .attr("href", "javascript:void(0);") 73 | .text("Open images in tabs"); 74 | 75 | // Try submissions first 76 | let tabsOpenInsertPos = getInjectionElement("insertInTabsInsertPositionSubmissions"); 77 | if (tabsOpenInsertPos.length > 0) { 78 | tabsOpenInsertPos.first().after(openLink); 79 | } else { 80 | // Try other pages 81 | const testPaths = [ 82 | getInjectionPoint("insertInTabsInsertPositionGallery"), // Gallery/scraps 83 | getInjectionPoint("insertInTabsInsertPositionFavorites") // Favorites 84 | ]; 85 | 86 | // Iterate through each test path until we find a valid one 87 | for (let i = 0; i < testPaths.length; i++) { 88 | tabsOpenInsertPos = jQuery(testPaths[i]); 89 | if (tabsOpenInsertPos.length > 0) break; 90 | } 91 | 92 | // Abort if not found 93 | if (tabsOpenInsertPos.length === 0) { 94 | Logger.error("Bad tabs open selector, aborting"); 95 | return; 96 | } 97 | 98 | tabsOpenInsertPos.append("

").append(openLink); 99 | } 100 | 101 | return openLink; 102 | } 103 | 104 | injectBeta() { 105 | // Create Open in Tabs link 106 | const openDiv = jQuery("
").addClass("aligncenter"); 107 | const openLink = jQuery("") 108 | .attr("id", "__ext_fa_opentabs") 109 | .attr("href", "javascript:void(0);") 110 | .text("Open images in tabs") 111 | .appendTo(openDiv); 112 | 113 | // Try pages in order 114 | let tabsOpenInsertPos: JQuery; 115 | const testPaths = [ 116 | getInjectionPoint("insertInTabsInsertPositionSubmissions"), // Submissions 117 | getInjectionPoint("insertInTabsInsertPositionGallery"), // Gallery/scraps 118 | getInjectionPoint("insertInTabsInsertPositionFavorites") // Favorites 119 | ]; 120 | 121 | // Iterate through each test path until we find a valid one 122 | for (let i = 0; i < testPaths.length; i++) { 123 | tabsOpenInsertPos = jQuery(testPaths[i]); 124 | if (tabsOpenInsertPos.length > 0) break; 125 | } 126 | 127 | // Abort if not found 128 | if (tabsOpenInsertPos.length === 0) { 129 | Logger.error("Bad tabs open selector, aborting"); 130 | return; 131 | } 132 | 133 | openDiv.insertAfter(tabsOpenInsertPos); 134 | return openLink; 135 | } 136 | } 137 | 138 | export default function (base: import("./base").Base) { 139 | base.registerTarget(() => new OpenInTabs(), ["/gallery/", "/scraps/", "/favorites/", "/msg/submissions/"]); 140 | } 141 | -------------------------------------------------------------------------------- /app/options.ts: -------------------------------------------------------------------------------- 1 | // FAExtender settings 2 | 3 | import browser from "webextension-polyfill"; 4 | import { HighlighterKey, HighlightTypes, HightlightFields, SettingsKeys, SettingsKeyTypes } from "./common"; 5 | import { Logger } from "./logger"; 6 | 7 | async function loadOptions() { 8 | const keys = [ 9 | SettingsKeys.openintabs.no_delay, SettingsKeys.openintabs.unreverse, 10 | SettingsKeys.hotkeys.enabled, 11 | SettingsKeys.highlighter.keys, 12 | SettingsKeys.downloader.subfolder, SettingsKeys.downloader.artist_subdirs 13 | ]; 14 | 15 | const obj = await browser.storage.sync.get(keys); 16 | 17 | jQuery("#delaytabs").prop("checked", !obj.openintabs_nodelay); 18 | jQuery("#reversetabs").prop("checked", !obj.openintabs_unreverse); 19 | jQuery("#hotkeys").prop("checked", obj.hotkeys_enabled); 20 | jQuery("#savefolder").val(obj.save_folder); 21 | jQuery("#saveartistsubfolder").prop("checked", obj.save_subdirs); 22 | 23 | return loadHighlight(obj.highlighter_keys); 24 | } 25 | 26 | function setKey(key: T, value: SettingsKeyTypes[T]) { 27 | return browser.storage.sync.set({ [key]: value }); 28 | } 29 | 30 | function bindOptions() { 31 | jQuery("#delaytabs").on("change", (e) => setKey(SettingsKeys.openintabs.no_delay, !jQuery(e.target).prop("checked"))); 32 | jQuery("#reversetabs").on("change", (e) => setKey(SettingsKeys.openintabs.unreverse, !jQuery(e.target).prop("checked"))); 33 | jQuery("#hotkeys").on("change", (e) => setKey(SettingsKeys.hotkeys.enabled, jQuery(e.target).prop("checked"))); 34 | jQuery("#savefolder").on("change", (e) => setKey(SettingsKeys.downloader.subfolder, jQuery(e.target).val().toString())); 35 | jQuery("#saveartistsubfolder").on("change", (e) => setKey(SettingsKeys.downloader.artist_subdirs, jQuery(e.target).prop("checked"))); 36 | jQuery("#highlight_add").on("click", addNewHighlight); 37 | jQuery("#reset").on("click", () => { 38 | browser.storage.sync.clear().then(() => { 39 | window.location.reload(); 40 | }); 41 | }); 42 | } 43 | 44 | let highlightState: HighlighterKey[]; 45 | const highlightTranslate = { "submission": "Submission", "journal": "Journal", "title": "Title", "user": "Username" }; 46 | 47 | function loadHighlight(keys: HighlighterKey[]) { 48 | if (!keys) keys = []; 49 | highlightState = keys; 50 | 51 | // Get tbody 52 | const tableBody = jQuery("table#highlight_table tbody"); 53 | 54 | // Clear tbody rows 55 | tableBody.empty(); 56 | 57 | keys.forEach((item, i) => { 58 | const row = jQuery(""); 59 | row.data("id", i + 1); 60 | 61 | jQuery("").text(highlightTranslate[item.type]).appendTo(row); 62 | jQuery("").text(highlightTranslate[item.field]).appendTo(row); 63 | jQuery("").text(item.text).appendTo(row); 64 | jQuery("").text(item.color).css("background-color", item.color).appendTo(row); 65 | const removeCol = jQuery("").appendTo(row); 66 | jQuery("").attr("type", "button").on("click", removeHighlight).appendTo(removeCol).val("-"); 67 | 68 | tableBody.append(row); 69 | }); 70 | } 71 | 72 | async function saveHighlight() { 73 | // Enforce data integrety 74 | if (!Array.isArray(highlightState)) return; 75 | 76 | // Save to storage 77 | await setKey(SettingsKeys.highlighter.keys, highlightState); 78 | loadHighlight(highlightState); 79 | } 80 | 81 | function addNewHighlight() { 82 | const tableFooter = jQuery("table#highlight_table tfoot"); 83 | const type = jQuery("select#highlight_type option:selected", tableFooter).val() as HighlightTypes; 84 | const field = jQuery("select#highlight_field option:selected", tableFooter).val() as HightlightFields; 85 | const text = jQuery("input#highlight_text", tableFooter).val().toString(); 86 | const color = jQuery("input#highlight_color", tableFooter).val().toString(); 87 | 88 | highlightState.push({ type, field, text, color }); 89 | return saveHighlight(); 90 | } 91 | 92 | function removeHighlight(e: JQuery.ClickEvent) { 93 | const button = jQuery(e.target); 94 | const row = button.parents("tr"); 95 | const id = row.data("id"); 96 | if (id <= 0) return; 97 | 98 | highlightState.splice(id - 1, 1); 99 | return saveHighlight(); 100 | } 101 | 102 | jQuery(document).on("ready", () => { 103 | (async function() { 104 | await loadOptions(); 105 | bindOptions(); 106 | })().catch((err) => { 107 | Logger.error("Got error processing options page", err); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /app/page_inject.ts: -------------------------------------------------------------------------------- 1 | // Load all the page injection scripts 2 | 3 | import jQuery from "jquery"; 4 | import { Base } from "./base"; 5 | import hookDownloader from "./downloader"; 6 | import hookHighlighter from "./highlighter"; 7 | import hookHotkeys from "./hotkeys"; 8 | import hookOpenInTabs from "./open_in_tabs"; 9 | import hookStoryInGdocs from "./story_in_gdocs"; 10 | 11 | const base = new Base(); 12 | hookDownloader(base); 13 | hookHighlighter(base); 14 | hookHotkeys(base); 15 | hookOpenInTabs(base); 16 | hookStoryInGdocs(base); 17 | 18 | jQuery(document).on("DOMContentLoaded", () => { 19 | base.onPageLoad(); 20 | }); 21 | -------------------------------------------------------------------------------- /app/story_in_gdocs.ts: -------------------------------------------------------------------------------- 1 | /* Story in GDocs */ 2 | 3 | import { StandardLoader } from "./loaderclasses"; 4 | import { getInjectionElement } from "./_injection_list"; 5 | import { getSiteVersion } from "./common_fa"; 6 | 7 | class StoryInGDocs extends StandardLoader { 8 | init() { 9 | // Reject if already injected 10 | if (jQuery("#__ext_fa_gdoclink").length > 0) return; 11 | 12 | // Find injection location 13 | const injectionPoint = getInjectionElement("openInGDocsInsertPosition"); 14 | if (injectionPoint.length === 0) { 15 | // No injection point 16 | return; 17 | } 18 | 19 | // Get download URL 20 | const url = this.getFullUrl(); 21 | if (!url) { 22 | // No download link 23 | return; 24 | } 25 | 26 | if (getSiteVersion() === "beta") { 27 | this.injectBeta(injectionPoint, url); 28 | } else { 29 | this.injectClassic(injectionPoint, url); 30 | } 31 | } 32 | 33 | injectClassic(injectionPoint: JQuery, downloadUrl: string) { 34 | // Get the parent 35 | const dLinkContainer = injectionPoint.parent(); 36 | 37 | // Append new link 38 | jQuery(" | View in GDocs").insertAfter(dLinkContainer); 39 | } 40 | 41 | injectBeta(injectionPoint: JQuery, downloadUrl: string) { 42 | // Get the target section 43 | const dLinkContainer = injectionPoint.parent().find("section.buttons"); 44 | 45 | // Append new container 46 | const section = jQuery("
").addClass("buttons").insertAfter(dLinkContainer); 47 | const button = jQuery("
").addClass("download").appendTo(section); 48 | jQuery("") 49 | .attr("id", "__ext_fa_gdoclink") 50 | .attr("href", "https://docs.google.com/viewer?url=" + encodeURI(downloadUrl)) 51 | .text("View in GDocs") 52 | .appendTo(button); 53 | } 54 | 55 | private getFullUrl() { 56 | const downloadLink = getInjectionElement("downloadLink"); 57 | 58 | // Get and fix URL 59 | let url = downloadLink.attr("href"); 60 | 61 | // Fix protocol-less URLs 62 | if (url.substr(0, 2) === "//") { 63 | url = document.location.protocol + url; 64 | } 65 | 66 | // Make sure this is a story 67 | if (url.indexOf("stories") < 0) return; 68 | 69 | return url; 70 | } 71 | } 72 | 73 | export default function (base: import("./base").Base) { 74 | base.registerTarget(() => new StoryInGDocs(), ["/view/", "/full/"]); 75 | } 76 | -------------------------------------------------------------------------------- /app/tabdelay.ts: -------------------------------------------------------------------------------- 1 | /* Open in tabs delay */ 2 | 3 | let countdownURL = ""; 4 | let countdownTimer = 0; 5 | let urlLabel: JQuery = null; 6 | let delayLabel: JQuery = null; 7 | 8 | function tabDelayOnLoad() { 9 | // URL parameters: 10 | // url - Destination URL (encoded) 11 | // delay - Time to count down 12 | 13 | const urlParams = new URLSearchParams(window.location.search); 14 | countdownURL = urlParams.get("url"); 15 | countdownTimer = parseInt(urlParams.get("delay"), 10); 16 | urlLabel = jQuery("#redirURL"); 17 | delayLabel = jQuery("#redirTime"); 18 | 19 | if (!countdownURL || countdownURL === "false") { 20 | urlLabel.text("Error: Invalid URL"); 21 | return; 22 | } else if (!countdownTimer) { 23 | // Instead of aborting with an error, redirect immediately 24 | countdownTimer = 0; 25 | } 26 | 27 | urlLabel.text(countdownURL); 28 | urlLabel.attr("href", countdownURL); 29 | 30 | tabDelayCountdown(); 31 | } 32 | 33 | function tabDelayCountdown() { 34 | if (countdownTimer > 0) { 35 | document.title = "FurAffinity Redirecting in " + countdownTimer; 36 | delayLabel.text(`in ${countdownTimer} second${countdownTimer === 1 ? "" : "s"}.`); 37 | 38 | countdownTimer -= 1; 39 | setTimeout(tabDelayCountdown, 1000); 40 | } else { 41 | window.location.href = countdownURL; 42 | } 43 | } 44 | 45 | jQuery(document).on("ready", () => { 46 | tabDelayOnLoad(); 47 | }); 48 | -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheeplusplus/faextender-chrome/60e2fd504b8ee59b5f1be94e0d414c46dd9f691f/docs/icon.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FurAffinity Extender 5 | 6 | 7 | 8 |
FurAffinity Extender icon FurAffinity Extender
9 |
10 |

The FurAffinity Extender extension by Kauko.

11 |

Install now (Chrome)

12 |

Install now (Firefox)

13 | Follow us on Twitter 14 | - Fork us on GitHub 15 |
16 |
17 | FurAffinity Extender is open source! Visit our GitHub project.
18 |
19 |
Features
20 |

FurAffinity Extender has the following features. 21 |

    22 |
  • Adds a download button to image pages
  • 23 |
  • Adds an open gallery or submissions page in tabs link to open up all images in a gallery or submission page at once (with optional delay to avoid timeouts)
  • 24 |
  • Adds an option to enable hotkeys - left/p moves previous, right/n moves next, f adds to favorites, s saves the file
  • 25 |
  • Highlight submissions and journals by title or username
  • 26 |
  • Open source
  • 27 |

28 |
29 |

30 | Have a suggestion? Send a Twitter DM, 31 | a note on FA 32 | or file an issue on Github. 33 |

34 | 35 | 36 | -------------------------------------------------------------------------------- /manifest/chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.2.4", 3 | "minimum_chrome_version": "88", 4 | "background": { 5 | "service_worker": "background.bundle.js", 6 | "type": "module" 7 | } 8 | } -------------------------------------------------------------------------------- /manifest/firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.2.4", 3 | "background": { 4 | "scripts": ["background.bundle.js"] 5 | }, 6 | "browser_specific_settings": { 7 | "gecko": { 8 | "id": "faextender@neocodenetworks.com", 9 | "strict_min_version": "109.0" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /manifest/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FurAffinity Extender", 3 | "manifest_version": 3, 4 | "description": "FurAffinity Extender adds to the your browsing experience of FurAffinity.net by adding additional client-side features.", 5 | "icons": { 6 | "48": "icon48.png", 7 | "128": "icon128.png" 8 | }, 9 | "content_scripts": [{ 10 | "matches": ["*://*.furaffinity.net/*"], 11 | "js": ["page_inject.bundle.js", "vendor.bundle.js"], 12 | "run_at": "document_start" 13 | }], 14 | "web_accessible_resources": [{ 15 | "resources": ["tabdelay.html"], 16 | "matches": ["*://*.furaffinity.net/*"] 17 | }], 18 | "homepage_url": "https://cheeplusplus.github.io/faextender-chrome/", 19 | "options_ui": { 20 | "page": "options.html" 21 | }, 22 | "permissions": [ 23 | "storage", 24 | "downloads" 25 | ] 26 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "faextender-chrome", 3 | "version": "0.0.0", 4 | "description": "FurAffinity Extender adds to the your browsing experience of FurAffinity.net by adding additional client-side features.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/cheeplusplus/faextender-chrome.git" 8 | }, 9 | "author": "Kauko ", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/cheeplusplus/faextender-chrome/issues" 13 | }, 14 | "homepage": "https://github.com/cheeplusplus/faextender-chrome#readme", 15 | "private": true, 16 | "scripts": { 17 | "build": "npm-run-all build:webpack:*", 18 | "build:debug": "webpack", 19 | "build:webpack:chrome": "webpack --env PACKAGE_TARGET=chrome --env production", 20 | "build:webpack:firefox": "webpack --env PACKAGE_TARGET=firefox --env production", 21 | "lint": "eslint --ext .js,.ts app/" 22 | }, 23 | "devDependencies": { 24 | "@types/jquery": "^3.3.31", 25 | "@types/webextension-polyfill": "^0.10.7", 26 | "@typescript-eslint/eslint-plugin": "^6.13.2", 27 | "@typescript-eslint/parser": "^6.13.2", 28 | "clean-webpack-plugin": "^4.0.0", 29 | "copy-webpack-plugin": "^11.0.0", 30 | "eslint": "^8.55.0", 31 | "html-webpack-plugin": "^5.5.3", 32 | "merge-jsons-webpack-plugin": "^2.0.1", 33 | "npm-run-all": "^4.1.5", 34 | "ts-loader": "^9.5.1", 35 | "typescript": "^5.3.2", 36 | "webpack": "^5.89.0", 37 | "webpack-cli": "^5.1.4", 38 | "zip-webpack-plugin": "^4.0.1" 39 | }, 40 | "dependencies": { 41 | "jquery": "^3.4.1", 42 | "jquery.hotkeys": "^0.1.0", 43 | "webextension-polyfill": "^0.10.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheeplusplus/faextender-chrome/60e2fd504b8ee59b5f1be94e0d414c46dd9f691f/src/icon128.png -------------------------------------------------------------------------------- /src/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheeplusplus/faextender-chrome/60e2fd504b8ee59b5f1be94e0d414c46dd9f691f/src/icon32.png -------------------------------------------------------------------------------- /src/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheeplusplus/faextender-chrome/60e2fd504b8ee59b5f1be94e0d414c46dd9f691f/src/icon48.png -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FurAffinity Extender Settings 7 | 8 | 9 | 10 | 11 | 12 |

FurAffinity Extender Settings

13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 |
26 |
27 |

Save in folder settings

28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 |

Journal/submission highlighting

40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 53 | 59 | 60 | 61 | 62 | 63 | 64 |
TypeFieldField contains textBackground color
48 | 52 | 54 | 58 |
65 |
66 |
67 |
68 | in case something exploded 69 | 70 | -------------------------------------------------------------------------------- /src/tabdelay.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FurAffinity Redirecting 7 | 8 | 9 | 10 | 11 | 12 |
13 | FurAffinity Extender
14 |
15 | is now redirecting you to
16 |
17 | 18 |
19 | 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES6", 4 | "target": "ES6", 5 | "noImplicitAny": true, 6 | "moduleResolution": "node", 7 | "allowJs": true, 8 | "sourceMap": true, 9 | "outDir": "build", 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true 12 | }, 13 | "include": [ 14 | "app/**/*" 15 | ] 16 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 4 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 5 | const MergeJsonWebpackPlugin = require("merge-jsons-webpack-plugin"); 6 | const ZipWebpackPlugin = require("zip-webpack-plugin"); 7 | 8 | module.exports = (env) => { 9 | env = env || {}; 10 | const PACKAGE_TARGET = env.PACKAGE_TARGET || "chrome"; 11 | 12 | const config = { 13 | "mode": env.production ? "production" : "development", 14 | "entry": { 15 | "background": "./app/background.ts", 16 | "options": "./app/options.ts", 17 | "page_inject": "./app/page_inject.ts", 18 | "tabdelay": "./app/tabdelay.ts" 19 | }, 20 | "module": { 21 | "rules": [ 22 | { 23 | "test": /\.tsx?$/, 24 | "use": "ts-loader", 25 | "exclude": /node_modules/ 26 | } 27 | ] 28 | }, 29 | "resolve": { 30 | "extensions": [".tsx", ".ts", ".js"] 31 | }, 32 | "output": { 33 | "filename": "[name].bundle.js", 34 | "path": path.resolve(__dirname, "build", `packed-${PACKAGE_TARGET}`) 35 | }, 36 | "devtool": "inline-source-map", 37 | "plugins": [ 38 | new CleanWebpackPlugin(), 39 | new webpack.ProvidePlugin({ 40 | "$": "jquery", 41 | "jQuery": "jquery", 42 | "window.jQuery": "jquery" 43 | }), 44 | new CopyWebpackPlugin({ 45 | patterns: [{ "from": "src" }] 46 | }), 47 | new MergeJsonWebpackPlugin({ 48 | "files": [ 49 | "./manifest/manifest.json", 50 | `./manifest/${PACKAGE_TARGET}.json` 51 | ], 52 | "output": { 53 | "fileName": "manifest.json" 54 | } 55 | }), 56 | new ZipWebpackPlugin({ 57 | "path": path.resolve(__dirname, "build"), 58 | "filename": `faextender_${PACKAGE_TARGET}.zip`, 59 | "exclude": [/\.map$/] 60 | }) 61 | ], 62 | "optimization": { 63 | "splitChunks": { 64 | "chunks": (chunk) => chunk.name !== 'background', 65 | "cacheGroups": { 66 | "vendors": { 67 | "test": /[\\/]node_modules[\\/]/, 68 | "name": "vendor" 69 | } 70 | } 71 | }, 72 | "usedExports": true 73 | } 74 | }; 75 | 76 | if (env.production) { 77 | // Use regular source maps instead of inline 78 | config.devtool = "source-map"; 79 | } 80 | 81 | return config; 82 | }; 83 | --------------------------------------------------------------------------------