├── .github ├── actions │ └── build │ │ └── action.yaml ├── renovate.json └── workflows │ ├── ci.yaml │ ├── deploy-preview.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── DEVELOPMENT.md └── assets │ ├── menu.png │ ├── settings.png │ └── tooltip.png ├── metadata.js ├── package-lock.json ├── package.json ├── src ├── check.ts ├── dataTypes.ts ├── index.ts ├── jobQueue.ts ├── observer.ts ├── request.ts ├── settings │ ├── display.ts │ ├── endpoints.ts │ ├── general.ts │ ├── menu.ts │ ├── settings.ts │ ├── statistics.ts │ └── storage.ts ├── stashChecker.ts ├── style │ ├── main.scss │ ├── main_important.scss │ └── theme.ts ├── tooltip │ ├── stashQuery.ts │ ├── tooltip.ts │ └── tooltipElement.ts └── utils.ts ├── tsconfig.json └── webpack.config.js /.github/actions/build/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | description: "Builds the app" 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Setup node 7 | uses: actions/setup-node@v3 8 | with: 9 | node-version: "lts/*" 10 | cache: "npm" 11 | - name: Install dependencies 12 | shell: bash 13 | run: npm ci 14 | - name: Build 15 | shell: bash 16 | run: npm run build 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["schedule:monthly", ":masterIssue", "config:base"], 3 | "prHourlyLimit": 0, 4 | "lockFileMaintenance": { 5 | "extends": ["schedule:weekly"], 6 | "automerge": true, 7 | "enabled": true 8 | }, 9 | "postUpdateOptions": ["npmDedupe"], 10 | "separateMajorMinor": false, 11 | "updateNotScheduled": false, 12 | "rangeStrategy": "bump" 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - gh-pages 7 | - "renovate/**" 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "lts/*" 19 | cache: "npm" 20 | 21 | - run: npm ci 22 | - run: npm run build 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy-preview.yaml: -------------------------------------------------------------------------------- 1 | name: deploy-preview 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ./.github/actions/build 14 | 15 | - name: Deploy Preview 16 | uses: peaceiris/actions-gh-pages@v3 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | publish_dir: ./dist 20 | publish_branch: preview-dist 21 | commit_message: deploy ${{ github.ref }} 22 | enable_jekyll: true 23 | user_name: github-actions[bot] 24 | user_email: github-actions[bot]@users.noreply.github.com 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ./.github/actions/build 14 | 15 | - name: Get version from tag 16 | id: tag_name 17 | run: | 18 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 19 | shell: bash 20 | - name: Get Changelog Entry 21 | id: changelog_reader 22 | uses: mindsers/changelog-reader-action@v2 23 | with: 24 | validation_level: warn 25 | version: ${{ steps.tag_name.outputs.current_version }} 26 | path: ./CHANGELOG.md 27 | - name: Deploy Gist 28 | uses: exuanbo/actions-deploy-gist@v1 29 | with: 30 | token: ${{ secrets.TOKEN }} 31 | gist_id: 562b9363d491e3ee281cb46944445fcd 32 | gist_file_name: stash-checker.user.js 33 | gist_description: Checks if a Scene/Performer is present in Stash 34 | file_path: dist/index.prod.user.js 35 | file_type: text 36 | 37 | - name: Release 38 | uses: softprops/action-gh-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | with: 42 | files: dist/index.prod.* 43 | tag_name: ${{ steps.changelog_reader.outputs.version }} 44 | name: Release ${{ steps.changelog_reader.outputs.version }} 45 | body: ${{ steps.changelog_reader.outputs.changes }} 46 | fail_on_unmatched_files: true 47 | generate_release_notes: true 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .vscode 4 | dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [WIP] 3 | 4 | - Add support for studio websites: Vixen Media Group, MetArt Network, WowNetwork 5 | - Add support for websites: manyvids, warashi-asian-pornstars.fr, hobby.porn, pornhub, fansly, brazzers, clips4sale 6 | - Add showCheckMark setting 7 | - Add 'organized' flag to tooltip 8 | - Add custom rules to change the checkmark color based on graphQL filters (WIP) 9 | - Use floating-ui for better tooltip positioning 10 | - Fix indexxx performer search results 11 | 12 | ## [1.0.0] 13 | 14 | ### New Features 15 | - Add metadata for userscript auto-updates [#27] 16 | - Add option to disable cross mark display 17 | - Add option to set custom symbol icons [#21] 18 | - Add tags, birthdate, height to tooltip [#4] 19 | - Add options to not include tags/files in tooltip [#22] 20 | - Add matching statistics to settings [#32] 21 | - Add support for websites: javdb, pmvhaven, xcity.jp, adultfilmdatabase 22 | 23 | ### Improvements / Fixes 24 | - Improve responsiveness for sites with many entries by queueing batch queries [#28] 25 | - Rerun queries when closing settings modal [#31] 26 | - Separate tooltip entries per endpoint 27 | - Update TPDB to new site 28 | - Fix data18 performer overview page 29 | - Fix page change on Stash-box not detected [#25] 30 | 31 | ## [0.9.3] 32 | 33 | - Update coomer.su and kemono.su 34 | 35 | ## [0.9.2] 36 | 37 | - Ignore create button on stash-box 38 | 39 | ## [0.9.1] 40 | 41 | - Fix bug where multiple batch requests per endpoint don't work 42 | 43 | ## [0.9.0] 44 | 45 | - Change license to MIT 46 | - Use batch requests to speed up websites with many entries [#24] 47 | - Improve messages for endpoint connection problems 48 | 49 | ## [0.8.4] 50 | 51 | - Fix IAFD scene query 52 | - Add IAFD studio query 53 | 54 | ## [0.8.3] 55 | 56 | - Add Stash endpoint connection check 57 | 58 | ## [0.8.2] 59 | 60 | - Fix github actions 61 | 62 | ## [0.8.1] 63 | 64 | - Automated release text generation 65 | - Fix version number 66 | 67 | ## [0.8.0] 68 | 69 | - Add matching quality based on number of matched queries 70 | - Fix tooltip separator color 71 | 72 | ## [0.7.1] 73 | 74 | - Fix default stash url 75 | 76 | ## [0.7.0] 77 | 78 | - Support for multiple Stash endpoints [#7] 79 | - Settings UI for Stash endpoints [#3] (further settings will be added later) 80 | 81 | ## [0.6.6] 82 | 83 | - Add dark mode [#14] 84 | - Add Stash-box studio matching [#13] 85 | - Add Stash-box tag matching (not yet supported by Stash) [#13] 86 | - Create userscript metadata files on release (not used yet) 87 | - Remove debug messages from released userscript 88 | 89 | ## [0.6.5] 90 | 91 | - Fix iwara.tv and metadataapi.net 92 | 93 | ## [0.6.4] 94 | 95 | - Some performance improvements and a small fix for movie title matching. 96 | 97 | ## [0.6.3] 98 | 99 | - Small fixes to title matching and updated dependencies. 100 | 101 | ## [0.6.2] 102 | 103 | - Update license 104 | 105 | ## [0.6.1] 106 | 107 | - Add websites: fansdb.cc, gayeroticvideoindex.com, onlyfans.com, www.pornteengirl.com, shemalestardb.com 108 | - Improve metadataapi.net 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022-2024 timo95 <24251362+timo95@users.noreply.github.com> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stash Checker 2 | 3 | Stash Checker is an userscript for porn websites to check if a Scene/Performer is in your [Stash](https://github.com/stashapp/stash) instance. 4 | It shows a checkmark if an item was found in your Stash. 5 | Hovering over the checkmark gives you a tooltip with information about the item in your Stash. 6 | 7 | tooltip 8 | 9 | ## Features 10 | 11 | - A tooltip for matched entries including basic metadata and a link to the entry 12 | - Supported websites: StashDB, TPDB, IAFD, JavLibrary and many more (see `@match` section in the userscript; go [here](https://github.com/timo95/stash-checker/issues/5) to request more) 13 | - Many different types of entries: Scene, Performer, Movie, Gallery, Studio and Tag (not yet supported by Stash) 14 | - Match entries by: StashId, URL, Studio Code, Name and Title 15 | - Multiple Stash endpoints 16 | - Dark mode (check your browser preferences) 17 | 18 | ## Installation 19 | 20 | You need a browser plugin like [Tampermonkey](https://www.tampermonkey.net/) or [Violentmonkey](https://violentmonkey.github.io/) to run userscripts. 21 | 22 | The newest release of Stash Checker can be found in the release section to the right. 23 | Opening `index.prod.user.js` under `Assets` should prompt Tampermonkey to install the script. 24 | 25 | Chrome users may have to activate developer mode to run userscripts (see [Tampermonkey FAQ](https://www.tampermonkey.net/faq.php#Q209)) 26 | 27 | ## Settings 28 | 29 | Settings can be opened on any supported website using the Tampermonkey dropdown menu. 30 | 31 | menu 32 | 33 | Here you can edit the Stash URL and API key or add another Stash endpoint. 34 | 35 | settings 36 | 37 | ## Troubleshooting 38 | 39 | ### Stash endpoint shows `no connection` 40 | 41 | - Check for the correct URL. It should include the scheme (`http`/`https`) at the beginning and end with `/graphql`. 42 | - Check the API key. Leave the field empty, if none is required. 43 | - Tampermonkey may block the connection. Make sure, that the domain is whitelisted in the Tampermonkey settings. 44 | - Firefox's "HTTPS only mode" can block a connection, if the URL uses `http` but does not include `localhost`. The whitelist doesn't help, you have to deactivate the feature. 45 | - Some websites can block connections due to a strict Content-Security-Policy-Header (CSP). Change Tampermonkey settings to remove this header. 46 | 47 | ### Stash endpoint shows `wrong URL` 48 | 49 | - Check for the correct URL. It should include the scheme (`http`/`https`) at the beginning and end with `/graphql`. 50 | - Some users had problems with Tampermonkey which got fixed by switching to Violentmonkey. 51 | 52 | ## Development 53 | 54 | See [here](docs/DEVELOPMENT.md). 55 | -------------------------------------------------------------------------------- /docs/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | 1. Allow Tampermonkey's access to local file URIs [tampermonkey/faq](https://tampermonkey.net/faq.php?ext=dhdg#Q204) 4 | 2. Install deps with `npm i` or `npm ci`. 5 | 3. `npm run dev` to start your development. 6 | Now you will see 2 files in `/dist/` 7 | - `dist/index.dev.proxy.user.js`: **You should install this userscript in your browser.** 8 | It's a simple loader that loads `dist/index.dev.user.js` on matched websites. 9 | - `dist/index.dev.user.js`: This is the development build with `source-map`. 10 | It will be automatically loaded by `dist/index.dev.user.js`. 11 | **Don't add it to your userscript manager.** 12 | 13 | Livereload is default enabled, use [this Chrome extension](https://chrome.google.com/webstore/detail/jnihajbhpnppcggbcgedagnkighmdlei) 14 | 15 | ### NOTICE 16 | 17 | Everytime you change your metadata config, 18 | you'll have to restart the webpack server and install the newly generated `dist/index.dev.user.js` UserScript in your browser again. 19 | 20 | ## Dependencies 21 | 22 | There are two ways to using a package on npm. 23 | 24 | ### UserScript way 25 | 26 | Like the original UserScript way, you will need to add them to your [user script metadata](../metadata.js)'s require section and exclude them in [config/webpack.config.base.cjs](../config/webpack.config.base.cjs) 27 | 28 | ### Webpack way 29 | 30 | Just install packages with npm and import them in your code, webpack will take care them. 31 | 32 | ## Build 33 | 34 | ```bash 35 | npm run build 36 | ``` 37 | 38 | `dist/index.prod.user.js` is the final script. 39 | 40 | `dist/index.prod.meta.js` can be used to compare the script version without having to download the whole script. 41 | 42 | ## Automatic Deploy 43 | 44 | [Github Actions](../.github/workflows/release.yaml) will deploy the production userscript to this [Gist](https://gist.github.com/timo95/562b9363d491e3ee281cb46944445fcd) on each new version tag. 45 | -------------------------------------------------------------------------------- /docs/assets/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timo95/stash-checker/224ef08c1db5c4ca1bebd51d4f0831ce3e403ef8/docs/assets/menu.png -------------------------------------------------------------------------------- /docs/assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timo95/stash-checker/224ef08c1db5c4ca1bebd51d4f0831ce3e403ef8/docs/assets/settings.png -------------------------------------------------------------------------------- /docs/assets/tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timo95/stash-checker/224ef08c1db5c4ca1bebd51d4f0831ce3e403ef8/docs/assets/tooltip.png -------------------------------------------------------------------------------- /metadata.js: -------------------------------------------------------------------------------- 1 | import meta from "./package.json" with { type: "json" }; 2 | 3 | export default { 4 | name: "Stash Checker", 5 | description: meta.description, 6 | icon: "https://docs.stashapp.cc/assets/images/favicon.ico", 7 | version: meta.version, 8 | author: meta.author.name, 9 | source: meta.repository.url, 10 | updateURL: meta.repository.url + "/releases/latest/download/index.prod.meta.js", 11 | downloadURL: meta.repository.url + "/releases/latest/download/index.prod.user.js", 12 | license: 'MIT', 13 | match: [ 14 | "*://adultanime.dbsearch.net/*", 15 | "*://www.brazzers.com/*", 16 | "*://coomer.su/*", 17 | "*://erommdtube.com/*", 18 | "*://fansdb.cc/*", 19 | "*://fansdb.xyz/*", 20 | "*://fansly.com/*", 21 | "*://www.slayed.com/*", 22 | "*://www.blacked.com/*", 23 | "*://www.tushy.com/*", 24 | "*://www.vixen.com/*", 25 | "*://www.blackedraw.com/*", 26 | "*://www.tushyraw.com/*", 27 | "*://www.deeper.com/*", 28 | "*://www.milfy.com/*", 29 | "*://www.wifey.com/*", 30 | "*://www.angelslove.xxx/*", 31 | "*://www.sensuallove.xxx/*", 32 | "*://www.wowgirlsblog.com/*", 33 | "*://www.ultrafilms.xxx/*", 34 | "*://www.18onlygirlsblog.com/*", 35 | "*://www.metart.com/*", 36 | "*://www.metartx.com/*", 37 | "*://www.sexart.com/*", 38 | "*://www.vivthomas.com/*", 39 | "*://www.thelifeerotic.com/*", 40 | "*://www.straplez.com/*", 41 | "*://www.errotica-archives.com/*", 42 | "*://www.domai.com/*", 43 | "*://www.goddessnudes.com/*", 44 | "*://www.eroticbeauty.com/*", 45 | "*://www.lovehairy.com/*", 46 | "*://www.alsscan.com/*", 47 | "*://www.rylskyart.com/*", 48 | "*://www.eternaldesire.com/*", 49 | "*://gayeroticvideoindex.com/*", 50 | "*://hobby.porn/*", 51 | "*://javdb.com/*", 52 | "*://javstash.org/*", 53 | "*://kemono.su/*", 54 | "*://onlyfans.com/*", 55 | "*://oreno3d.com/*", 56 | "*://pmvhaven.com/*", 57 | "*://pmvstash.org/*", 58 | "*://r18.dev/*", 59 | "*://shemalestardb.com/*", 60 | "*://stashdb.org/*", 61 | "*://theporndb.net/*", 62 | "*://warashi-asian-pornstars.fr/*", 63 | "*://www.adultfilmdatabase.com/*", 64 | "*://www.animecharactersdatabase.com/*", 65 | "*://www.babepedia.com/*", 66 | "*://www.clips4sale.com/*", 67 | "*://www.data18.com/*", 68 | "*://www.freeones.com/*", 69 | "*://www.iafd.com/*", 70 | "*://www.indexxx.com/*", 71 | "*://www.iwara.tv/*", 72 | "*://www.javlibrary.com/*", 73 | "*://www.manyvids.com/*", 74 | "*://www.minnano-av.com/*", 75 | "*://www.pornhub.com/*", 76 | "*://www.pornteengirl.com/*", 77 | "*://www.thenude.com/*", 78 | "*://xcity.jp/*", 79 | "*://xslist.org/*", 80 | ], 81 | require: [], 82 | grant: [ 83 | "GM.xmlHttpRequest", 84 | "GM.getValue", 85 | "GM.setValue", 86 | "GM.deleteValue", 87 | "GM.listValues", 88 | "GM.registerMenuCommand", 89 | ], 90 | connect: [ 91 | "localhost", 92 | "*" 93 | ], 94 | "run-at": "document-end", 95 | }; 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stash-checker", 3 | "description": "Add checkmarks on porn websites to scenes/performers that are present in your own Stash instance.", 4 | "version": "1.1.0", 5 | "author": { 6 | "name": "timo95", 7 | "email": "24251362+timo95@users.noreply.github.com" 8 | }, 9 | "scripts": { 10 | "build": "cross-env NODE_ENV=production webpack --config webpack.config.js", 11 | "dev": "cross-env NODE_ENV=development webpack serve --config webpack.config.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/timo95/stash-checker" 16 | }, 17 | "type": "module", 18 | "private": true, 19 | "devDependencies": { 20 | "@floating-ui/dom": "^1.7.0", 21 | "@types/greasemonkey": "^4.0.7", 22 | "@types/sortablejs": "^1.15.8", 23 | "browserslist": "^4.25.0", 24 | "cross-env": "^7.0.3", 25 | "css-loader": "^7.1.2", 26 | "cssimportant-loader": "^0.4.0", 27 | "sass": "^1.89.1", 28 | "sass-loader": "^16.0.5", 29 | "sortablejs": "^1.15.6", 30 | "style-loader": "^4.0.0", 31 | "terser-webpack-plugin": "^5.3.14", 32 | "ts-loader": "^9.5.2", 33 | "typescript": "^5.8.3", 34 | "urlpattern-polyfill": "^10.1.0", 35 | "webpack": "^5.99.9", 36 | "webpack-cli": "^6.0.1", 37 | "webpack-dev-server": "^5.2.1", 38 | "webpack-sources": "^3.3.0", 39 | "webpack-userscript": "^3.2.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/check.ts: -------------------------------------------------------------------------------- 1 | import {prefixSymbol} from "./tooltip/tooltip"; 2 | import {stashEndpoints} from "./settings/endpoints"; 3 | import {firstText, hasKanji, nakedDomain} from "./utils"; 4 | import {CheckOptions, CustomDisplayRule, DataField, DisplayOptions, StashEndpoint, Target, Type} from "./dataTypes"; 5 | import {request} from "./request"; 6 | import {booleanOptions, OptionKey} from "./settings/general"; 7 | import {onAddition} from "./observer"; 8 | import {customDisplayRules} from "./settings/display"; 9 | 10 | // Conditional ESM module loading (Node.js and browser) 11 | // @ts-ignore: Property 'UrlPattern' does not exist 12 | /*if (!globalThis.URLPattern) { 13 | await import("urlpattern-polyfill"); 14 | }*/ // Short Form not yet supported by native chromium implementation -> always polyfill 15 | import {URLPattern} from "urlpattern-polyfill"; 16 | 17 | const supportedDataFields = new Map([ 18 | [Target.Scene, [DataField.Id, DataField.Title, DataField.Organized, DataField.Studio, DataField.Code, DataField.Date, DataField.Tags, DataField.Files]], 19 | [Target.Performer, [DataField.Id, DataField.Name, DataField.Disambiguation, DataField.Favorite, DataField.AliasList, DataField.Birthdate, DataField.HeightCm, DataField.Tags]], 20 | [Target.Gallery, [DataField.Id, DataField.Title, DataField.Date, DataField.Tags, DataField.Files]], 21 | [Target.Movie, [DataField.Id, DataField.Name, DataField.Date]], 22 | [Target.Studio, [DataField.Id, DataField.Name, DataField.Aliases]], 23 | [Target.Tag, [DataField.Id, DataField.Name]], 24 | ]); 25 | 26 | const supportedSubDataFields = new Map([ 27 | [DataField.Studio, [DataField.Name]], 28 | [DataField.Tags, [DataField.Id, DataField.Name]], 29 | [DataField.Files, [DataField.Path, DataField.VideoCodec, DataField.Width, DataField.Height, DataField.Size, DataField.BitRate, DataField.Duration]], 30 | ]); 31 | 32 | function getDataFields(target: Target): string { 33 | let supported = new Set(supportedDataFields.get(target) ?? []) 34 | if (!booleanOptions.get(OptionKey.showTags)) { 35 | supported.delete(DataField.Tags) 36 | } 37 | if (!booleanOptions.get(OptionKey.showFiles)) { 38 | supported.delete(DataField.Files) 39 | } 40 | return Array.from(supported).map(field => field + getSubDataFields(field)).join(",") 41 | } 42 | 43 | function getSubDataFields(field: DataField): string { 44 | let supported = supportedSubDataFields.get(field) ?? [] 45 | let string = supported.join(",") 46 | return string ? `{${string}}` : "" 47 | } 48 | 49 | async function queryStash( 50 | queryString: string, 51 | onload: (target: Target, type: Type, endpoint: StashEndpoint, data: any[]) => any, 52 | target: Target, 53 | type: Type, 54 | customFilter: string, 55 | stashIdEndpoint: string 56 | ) { 57 | let filter: string; 58 | let query: string; 59 | let access = (d: any) => d; 60 | 61 | // Build filter 62 | switch (type) { 63 | case Type.StashId: 64 | filter = `stash_id_endpoint:{endpoint:"${encodeURIComponent(stashIdEndpoint)}",stash_id:"${encodeURIComponent(queryString)}",modifier:EQUALS}${customFilter}`; 65 | break; 66 | case Type.Url: 67 | filter = `${type}:{value:"""${encodeURIComponent(queryString)}""",modifier:INCLUDES}${customFilter}`; 68 | break; 69 | default: 70 | filter = `${type}:{value:"""${encodeURIComponent(queryString)}""",modifier:EQUALS}${customFilter}`; 71 | break; 72 | } 73 | 74 | // Build query 75 | switch (target) { 76 | case Target.Scene: 77 | query = `findScenes(scene_filter:{${filter}}){scenes{${getDataFields(target)}}}`; 78 | access = (d) => d.scenes; 79 | break; 80 | case Target.Performer: 81 | query = `findPerformers(performer_filter:{${filter}}){performers{${getDataFields(target)}}}`; 82 | access = (d) => d.performers; 83 | break; 84 | case Target.Gallery: 85 | query = `findGalleries(gallery_filter:{${filter}}){galleries{${getDataFields(target)}}}`; 86 | access = (d) => d.galleries; 87 | break; 88 | case Target.Movie: 89 | query = `findMovies(movie_filter:{${filter}}){movies{${getDataFields(target)}}}`; 90 | access = (d) => d.movies; 91 | break; 92 | case Target.Studio: 93 | query = `findStudios(studio_filter:{${filter}}){studios{${getDataFields(target)}}}`; 94 | access = (d) => d.studios; 95 | break; 96 | case Target.Tag: 97 | query = `findTags(tag_filter:{${filter}}){tags{${getDataFields(target)}}}`; 98 | access = (d) => d.tags; 99 | break; 100 | default: 101 | return; 102 | } 103 | 104 | // Get config values or wait for popup if it is not stored 105 | stashEndpoints.forEach((endpoint: StashEndpoint) => { 106 | request(endpoint, query, true) 107 | .then((data: any) => onload(target, type, endpoint, access(data))); 108 | }); 109 | } 110 | 111 | /** 112 | * For a given element query stash with each configured query. 113 | * Default selectors for most queries are defined here. 114 | */ 115 | async function checkElement( 116 | target: Target, 117 | element: Element, 118 | customFilter: string, 119 | display: DisplayOptions, 120 | { 121 | displaySelector = (e: Element) => e, 122 | urlSelector = (e: Element) => e.closest("a")?.href, 123 | codeSelector, 124 | stashIdSelector, 125 | stashIdEndpoint = `https://${window.location.host}/graphql`, 126 | nameSelector = firstText, 127 | titleSelector = firstText, 128 | }: CheckOptions 129 | ) { 130 | let displayElement = displaySelector(element) 131 | if (!displayElement) { 132 | return 133 | } 134 | 135 | if (urlSelector) { 136 | let url = urlSelector(element) 137 | if (url) { 138 | url = nakedDomain(url); 139 | console.debug(`URL: ${url}`); 140 | await queryStash(url, (...args) => prefixSymbol(displayElement!, ...args, display), target, Type.Url, customFilter, stashIdEndpoint); 141 | } else { 142 | console.info(`No URL for ${target} found.`); 143 | } 144 | } 145 | if (codeSelector) { 146 | let code = codeSelector(element); 147 | if (code) { 148 | console.debug(`Code: ${code}`); 149 | await queryStash(code, (...args) => prefixSymbol(displayElement!, ...args, display), target, Type.Code, customFilter, stashIdEndpoint); 150 | } else { 151 | console.info(`No Code for ${target} found.`); 152 | } 153 | } 154 | if (stashIdSelector) { 155 | let id = stashIdSelector(element); 156 | if (id) { 157 | console.debug(`StashId: ${id}`); 158 | await queryStash(id, (...args) => prefixSymbol(displayElement!, ...args, display), target, Type.StashId, customFilter, stashIdEndpoint); 159 | } else { 160 | console.info(`No StashId for ${target} found.`); 161 | } 162 | } 163 | if ([Target.Performer, Target.Movie, Target.Studio, Target.Tag].includes(target) && nameSelector) { 164 | let name = nameSelector(element); 165 | // Do not use single performer names 166 | let nameCount = name?.split(/\s+/)?.length 167 | let kanji = name ? hasKanji(name) : false 168 | let ignore = target === Target.Performer && nameCount === 1 && !kanji 169 | if (name && !ignore) { 170 | console.debug(`Name: ${name}`); 171 | await queryStash(name, (...args) => prefixSymbol(displayElement!, ...args, display), target, Type.Name, customFilter, stashIdEndpoint); 172 | } else if (name && ignore) { 173 | console.info(`Ignore single name: ${name}`) 174 | } else { 175 | console.info(`No Name for ${target} found.`); 176 | } 177 | } 178 | if ([Target.Scene, Target.Gallery].includes(target) && titleSelector) { 179 | let title = titleSelector(element); 180 | if (title) { 181 | console.debug(`Title: ${title}`); 182 | await queryStash(title, (...args) => prefixSymbol(displayElement!, ...args, display), target, Type.Title, customFilter, stashIdEndpoint); 183 | } else { 184 | console.info(`No Title for ${target} found.`); 185 | } 186 | } 187 | } 188 | 189 | function getCustomRules(target: Target): CustomDisplayRule[] { 190 | let targetRules = customDisplayRules.filter(rule => rule.target === target) 191 | return targetRules.filter(rule => new URLPattern(rule.pattern, self.location.href).test(window.location.href)) 192 | } 193 | 194 | /** 195 | * Combine filters with AND/NOT/OR recursively 196 | * Flat list has too many restrictions (no duplicate filter types, no AND / OR / NOT in the same filter) 197 | */ 198 | function combineFilters(customAndFilters: string[], customNotFilters: string[]): string { 199 | let andFilter = customAndFilters.map(f => ",AND:{" + f).join() 200 | let notFilter = customNotFilters.length == 0 ? "" : ",NOT:{" + customNotFilters.join(",OR:{") 201 | let closing = "}".repeat(customAndFilters.length + customNotFilters.length) 202 | return andFilter + notFilter + closing 203 | } 204 | 205 | /** 206 | * Resolves custom rules. Lower index equates to higher priority. 207 | * 208 | * Example: List of 3 custom rules results in these 4 query filters 209 | * 0 -> 0 210 | * 1 -> NOT 0 && 1 211 | * 2 -> NOT 0 && NOT 1 && 2 212 | * default -> NOT 0 && NOT 1 && NOT 2 213 | */ 214 | function checkWithCustomRules( 215 | target: Target, 216 | element: Element, 217 | checkConfig: CheckOptions 218 | ) { 219 | let customRules = getCustomRules(target) 220 | 221 | // filter for each rule 222 | for (let i = 0; i < customRules.length; i++) { 223 | let rule = customRules[i] 224 | let notFilters = customRules.slice(0, i).map(rule => rule.filter).map(emptyToTrue) 225 | let andFilters = [rule.filter].map(emptyToTrue) 226 | void checkElement(target, element, combineFilters(andFilters, notFilters), rule.display, checkConfig) 227 | } 228 | // default excluding all rules 229 | let notFilters = customRules.map(rule => rule.filter).map(emptyToTrue) 230 | console.log("default") 231 | void checkElement(target, element, combineFilters([], notFilters), {color: "green"}, checkConfig) 232 | } 233 | 234 | function emptyToTrue(s: string): string { 235 | return s.length > 0 ? s : "id:{value:-1,modifier:GREATER_THAN}" 236 | } 237 | 238 | /** 239 | * Queries for each selected element 240 | * 241 | * The selected element should be [a descendant of] the link that will be compared with stash urls. 242 | * The first text inside the selected element will be prepended with the symbol. 243 | */ 244 | export function check( 245 | target: Target, 246 | elementSelector: string, 247 | {observe = false, ...checkConfig}: CheckOptions = {} 248 | ) { 249 | // Run query on addition of new elements fitting the selector 250 | if (observe) { 251 | onAddition(elementSelector, (element: Element) => 252 | checkWithCustomRules(target, element, checkConfig) 253 | ); 254 | } 255 | document.querySelectorAll(elementSelector).forEach((e) => checkWithCustomRules(target, e, checkConfig)); 256 | } -------------------------------------------------------------------------------- /src/dataTypes.ts: -------------------------------------------------------------------------------- 1 | import {StashQuery} from "./tooltip/stashQuery"; 2 | 3 | /** 4 | * Represents a Stash GraphQL endpoint. 5 | */ 6 | export type StashEndpoint = { 7 | name: string, 8 | url: string, 9 | key: string, 10 | } 11 | 12 | /** 13 | * Return type of GraphQL queries representing an entry. 14 | */ 15 | export type StashEntry = { 16 | [key in DataField]: any | StashFile[]; 17 | } & { 18 | queries: StashQuery[]; 19 | endpoint: string; 20 | display: DisplayOptions; 21 | }; 22 | 23 | export type StashFile = { 24 | [key in DataField]: any; 25 | } 26 | 27 | /** 28 | * A batch collector of requests. 29 | */ 30 | export interface BatchQuery { 31 | timerHandle: number, 32 | queries: GraphQlQuery[], 33 | } 34 | 35 | /** 36 | * A graphql query and result handlers. 37 | */ 38 | export interface GraphQlQuery { 39 | query: string, 40 | resolve?: (data: any) => void, 41 | reject?: (message?: string) => void, 42 | } 43 | 44 | export interface DisplayOptions { 45 | color: string, 46 | } 47 | 48 | export interface CustomDisplayRule { 49 | target: Target, 50 | pattern: string, 51 | filter: string, 52 | display: DisplayOptions, 53 | } 54 | 55 | /** 56 | * Possible fields and subfields of the returned data 57 | */ 58 | export enum DataField { 59 | Aliases = "aliases", 60 | AliasList = "alias_list", 61 | Birthdate = "birthdate", 62 | BitRate = "bit_rate", 63 | Code = "code", 64 | Date = "date", 65 | Disambiguation = "disambiguation", 66 | Duration = "duration", 67 | Favorite = "favorite", 68 | Files = "files", 69 | Height = "height", 70 | HeightCm = "height_cm", 71 | Id = "id", 72 | Name = "name", 73 | Organized = "organized", 74 | Path = "path", 75 | Size = "size", 76 | Studio = "studio", 77 | Tags = "tags", 78 | Title = "title", 79 | VideoCodec = "video_codec", 80 | Width = "width", 81 | } 82 | 83 | export enum StashSymbol { 84 | Check = "check", 85 | Warning = "warning", 86 | Cross = "cross", 87 | } 88 | 89 | /** 90 | * Entry types in Stash 91 | */ 92 | export enum Target { 93 | Gallery = "gallery", 94 | Movie = "movie", 95 | Performer = "performer", 96 | Scene = "scene", 97 | Studio = "studio", 98 | Tag = "tag", 99 | } 100 | 101 | export function readable(target: Target): string { 102 | return target.charAt(0).toUpperCase() + target.slice(1); 103 | } 104 | 105 | export function readablePlural(target: Target): string { 106 | switch (target) { 107 | case Target.Gallery: return "Galleries"; 108 | default: return readable(target) + "s"; 109 | } 110 | } 111 | 112 | /** 113 | * Ways to query for an entry 114 | */ 115 | export enum Type { 116 | Code = "code", 117 | Name = "name", 118 | StashId = "stash_id", 119 | Title = "title", 120 | Url = "url", 121 | } 122 | 123 | /** 124 | * Possible themes 125 | */ 126 | export enum Theme { 127 | Light = "light", 128 | Dark = "dark", 129 | Device = "device" 130 | } 131 | 132 | /** 133 | * A function to select a [Type] query parameter from a given element. 134 | */ 135 | export type Selector = (e: Element) => null | undefined | string; 136 | 137 | /** 138 | * Configure queries for an entry. 139 | */ 140 | export interface CheckOptions { 141 | displaySelector?: (e: Element) => Element | null | undefined; 142 | urlSelector?: Selector | null; 143 | codeSelector?: Selector | null; 144 | stashIdSelector?: Selector | null; 145 | stashIdEndpoint?: string; 146 | nameSelector?: Selector | null; 147 | titleSelector?: Selector | null; 148 | observe?: boolean; 149 | } 150 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./style/main_important.scss"; 2 | import {initEndpointSettings} from "./settings/endpoints"; 3 | import {initSettingsWindow} from "./settings/settings"; 4 | import {initMenu} from "./settings/menu"; 5 | import {initGeneralSettings} from "./settings/general"; 6 | import {runStashChecker} from "./stashChecker"; 7 | import {initStatistics} from "./settings/statistics"; 8 | import {initDisplaySettings} from "./settings/display"; 9 | import {setTheme} from "./style/theme"; 10 | import {initTooltip} from "./tooltip/tooltipElement"; 11 | 12 | (async function () { 13 | await initTooltip(); 14 | initSettingsWindow(); 15 | initStatistics(); 16 | initGeneralSettings(); 17 | initDisplaySettings(); 18 | 19 | setTheme(); 20 | 21 | await initEndpointSettings(); 22 | await initMenu(); 23 | 24 | await runStashChecker(); 25 | })(); 26 | -------------------------------------------------------------------------------- /src/jobQueue.ts: -------------------------------------------------------------------------------- 1 | 2 | interface QueuedJob { 3 | job: () => Promise; 4 | resolve: (value: T) => void; 5 | reject: (reason?: any) => void; 6 | status: Status; 7 | } 8 | 9 | enum Status { 10 | WAITING, RUNNING, FINISHED 11 | } 12 | 13 | /** 14 | * Queue for jobs. Can run multiple jobs in parallel. 15 | */ 16 | export class JobQueue { 17 | public readonly parallel: number; 18 | private queue: QueuedJob[] = []; 19 | 20 | constructor(parallel: number = 1) { 21 | this.parallel = parallel; 22 | } 23 | 24 | /** 25 | * Add new job to queue. Promise will be resolved when the job is finished. 26 | * 27 | * @param job job that returns a promise 28 | */ 29 | public enqueue(job: () => Promise): Promise { 30 | return new Promise((resolve, reject) => { 31 | this.queue.push({ job: job, resolve, reject, status: Status.WAITING }); 32 | this.dequeue() 33 | }) 34 | } 35 | 36 | private dequeue() { 37 | // Get next waiting job, if less than [parallel] are running 38 | let job = this.queue.find((job, index) => index < this.parallel && job.status === Status.WAITING); 39 | if (job) { 40 | job.status = Status.RUNNING; 41 | console.debug(`Start job, remaining queue length: ${this.length()}`); 42 | job.job().then(job.resolve).catch(job.reject).finally(() => { 43 | job!.status = Status.FINISHED 44 | // Remove finished job 45 | this.queue = this.queue.filter((job) => job.status !== Status.FINISHED) 46 | console.debug(`Finished job, remaining queue length: ${this.length()}`); 47 | // Start next job 48 | this.dequeue() 49 | }) 50 | } 51 | } 52 | 53 | /** 54 | * Number of running and waiting jobs 55 | */ 56 | public length(): number { 57 | return this.queue.length 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/observer.ts: -------------------------------------------------------------------------------- 1 | 2 | const observerList: MutationObserver[] = [] 3 | 4 | /** 5 | * Run callback when a new element added to the document matches the selector. 6 | * 7 | * @param selector css selector string 8 | * @param callback callback function 9 | */ 10 | export function onAddition(selector: string, callback: (e: Element) => void) { 11 | let exclude = ".stashChecker, .stashCheckerSymbol" 12 | // Run on each element addition 13 | let observer = new MutationObserver((mutations) => { 14 | let addedElements = mutations 15 | .flatMap(m => Array.from(m.addedNodes)) 16 | .filter(n => n.nodeType === Node.ELEMENT_NODE) 17 | .map(n => n as Element) 18 | addedElements 19 | .filter(e => e.matches(selector)) 20 | .concat(addedElements.flatMap(e => Array.from(e.querySelectorAll(selector)))) 21 | .filter(e => !e.matches(exclude) && !e.parentElement?.matches(exclude)) 22 | .forEach(callback); 23 | }); 24 | let body = document.querySelector("body")!; 25 | observer.observe(body, {childList: true, subtree: true}); 26 | observerList.push(observer) 27 | } 28 | 29 | export function clearObservers() { 30 | while (observerList.length > 0) observerList.pop()?.disconnect() 31 | } 32 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import {BatchQuery, GraphQlQuery, StashEndpoint} from "./dataTypes"; 2 | import {friendlyHttpStatus} from "./utils"; 3 | import {JobQueue} from "./jobQueue"; 4 | 5 | const batchTimeout = 10; 6 | const maxBatchSize = 100; 7 | 8 | let batchQueries: Map = new Map(); 9 | let batchQueues: Map = new Map(); 10 | 11 | export async function request( 12 | endpoint: StashEndpoint, 13 | query: string, 14 | batchQueries: boolean = false 15 | ): Promise { 16 | if (batchQueries) { 17 | return addQuery(endpoint, query); 18 | } else { 19 | return new Promise((resolve, reject) => sendQuery(endpoint, `q:${query}`) 20 | .then((data: any) => resolve(data.q)) 21 | .catch(reject) 22 | ); 23 | } 24 | } 25 | 26 | async function addQuery( 27 | endpoint: StashEndpoint, 28 | query: string 29 | ): Promise { 30 | return new Promise((resolve, reject) => { 31 | let batchQueue = batchQueues.get(endpoint); 32 | if (!batchQueue) { 33 | // Init new queue. Every queue gets initialized once and never deleted 34 | batchQueue = new JobQueue(2) 35 | batchQueues.set(endpoint, batchQueue) 36 | } 37 | 38 | let batchQuery = batchQueries.get(endpoint) 39 | if (!batchQuery) { 40 | // Init new batch collector 41 | let timerHandle = window.setTimeout(() => { 42 | // Send batch after timeout and delete map entry 43 | let query = buildBatchQuery(endpoint, batchQueries.get(endpoint)!); 44 | batchQueue!.enqueue(() => sendQuery(endpoint, query.query)) 45 | .then(query.resolve) 46 | .catch(query.reject); 47 | batchQueries.delete(endpoint); 48 | }, batchTimeout) 49 | batchQuery = { 50 | timerHandle, 51 | queries: [], 52 | }; 53 | batchQueries.set(endpoint, batchQuery); 54 | } 55 | 56 | // Add new query to batch collector 57 | batchQuery.queries.push({ query, resolve, reject }); 58 | 59 | if (batchQuery.queries.length >= maxBatchSize) { 60 | // Send full batch and delete map entry 61 | window.clearTimeout(batchQuery.timerHandle); 62 | batchQueries.delete(endpoint); 63 | let query = buildBatchQuery(endpoint, batchQuery) 64 | return batchQueue.enqueue(() => sendQuery(endpoint, query.query)) 65 | .then(query.resolve) 66 | .catch(query.reject); 67 | } 68 | }); 69 | } 70 | 71 | function buildBatchQuery(endpoint: StashEndpoint, batchQuery: BatchQuery): GraphQlQuery { 72 | let query = batchQuery.queries.map((request, index) => `q${index}:${request.query}`).join() 73 | let resolve = (data: any) => { 74 | console.debug(`Received batch query response of size ${batchQuery.queries.length} from endpoint '${endpoint.name}'`) 75 | batchQuery.queries.forEach((request, index) => { 76 | if (request.resolve) request.resolve(data[`q${index}`]) 77 | }) 78 | } 79 | let reject = (message?: string) => { 80 | console.debug(`Received error for batch query of size ${batchQuery.queries.length} from endpoint '${endpoint.name}'`) 81 | batchQuery.queries.forEach((request) => { 82 | if (request.reject) request.reject(message); 83 | }) 84 | } 85 | console.info(`Build batch query of size ${batchQuery.queries.length} for endpoint '${endpoint.name}'`) 86 | return {query, resolve, reject}; 87 | } 88 | 89 | async function sendQuery( 90 | endpoint: StashEndpoint, 91 | query: string 92 | ): Promise { 93 | console.debug(`Sending query to endpoint '${endpoint.name}'`); 94 | return new Promise((resolve, reject) => { 95 | GM.xmlHttpRequest({ 96 | method: "GET", 97 | url: `${endpoint.url}?query={${query}}`, // encode query (important for url and some titles) 98 | headers: { 99 | "Content-Type": "application/json", 100 | ApiKey: endpoint.key, 101 | }, 102 | onload: (response) => { 103 | switch (response.status) { 104 | case 200: { 105 | try { 106 | let r = JSON.parse(response.responseText) 107 | if ("errors" in r) { 108 | r.errors.forEach((e: any) => { 109 | console.error(`Stash returned "${e.extensions.code}" error: ${e.message}`) 110 | reject(e.message); 111 | }); 112 | } else { 113 | resolve(r.data) 114 | } 115 | } catch (e) { 116 | console.debug("Failed to parse response: " + response.responseText); 117 | reject(response.responseText); 118 | } 119 | break; 120 | } 121 | default: { 122 | console.debug(`Error: Response code ${statusMessage(response.status, response.statusText)} for query: ${query}`); 123 | reject(response.responseText ?? statusMessage(response.status, response.statusText)); 124 | } 125 | } 126 | }, 127 | onerror: (response) => { 128 | console.debug(response); 129 | reject(response.responseText ?? statusMessage(response.status, response.statusText)); 130 | }, 131 | onabort() { 132 | reject("aborted"); 133 | }, 134 | ontimeout() { 135 | reject("timeout"); 136 | } 137 | }); 138 | }) 139 | } 140 | 141 | function statusMessage(status: number, statusText?: string): string { 142 | if (statusText && statusText.trim() !== "") { 143 | return `${status}: ${statusText}`; 144 | } else { 145 | return `${status}: ${friendlyHttpStatus.get(status)}`; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/settings/display.ts: -------------------------------------------------------------------------------- 1 | import {buttonDanger, buttonPrimary, getSettingsSection, newSettingsSection} from "./settings"; 2 | import {CustomDisplayRule, readable, Target} from "../dataTypes"; 3 | import {getValue, setValue, StorageKey} from "./storage"; 4 | import {OptionKey, stringOptions} from "./general"; 5 | import Sortable from 'sortablejs'; 6 | import {moveIndex} from "../utils"; 7 | 8 | // TODO: isActive indicator 9 | 10 | export const customDisplayRules: CustomDisplayRule[] = await getValue(StorageKey.CustomDisplayRules, []); 11 | 12 | export function initDisplaySettings() { 13 | let description = "Custom display rules can change the display of check marks. " + 14 | "A rule applies when the URL pattern matches the current website and the GraphQL filter matches the element. " + 15 | "Rules higher in the list have higher priority. " + 16 | "The order can be changed by dragging. " + 17 | "If no rule applies, the default display options are used. " + 18 | "GraphQL filters may not contain AND/OR/NOT. " + 19 | "Multiple filters can still be concatenated by ','. " + 20 | "Leave the filter empty to always apply." 21 | let displaySection = newSettingsSection("display", "Custom Display Rules", description); 22 | populateDisplaySection(displaySection); 23 | } 24 | 25 | // TODO: use tabs for targets? 26 | // TODO: separate modal/tab for display settings 27 | function populateDisplaySection(displaySection: HTMLElement) { 28 | let table = document.createElement("table"); 29 | let tableHead = document.createElement("thead"); 30 | tableHead.append(tableHeadRow()); 31 | table.append(tableHead); 32 | 33 | let tableBody = document.createElement("tbody"); 34 | tableBody.id = "stashChecker-displayRules" 35 | table.append(tableBody); 36 | displaySection.append(table); 37 | displaySection.append(document.createElement("br")); 38 | displaySection.append(buttonPrimary("Add Rule", addRuleListener)); 39 | Sortable.create(tableBody, { 40 | onEnd: event => { 41 | if (event.oldIndex && event.newIndex) { 42 | // Change data and update representation (data-index attributes) 43 | moveIndex(customDisplayRules, event.oldIndex, event.newIndex); 44 | populateCustomRulesTable(document.querySelector("#stashChecker-displayRules")!); 45 | } 46 | } 47 | }); 48 | populateCustomRulesTable(tableBody); 49 | } 50 | 51 | function populateCustomRulesTable(tableBody: HTMLTableSectionElement) { 52 | let tableRows = Array.from(customDisplayRules) 53 | .map(tableRow); 54 | tableBody.replaceChildren(...tableRows); 55 | } 56 | 57 | function tableHeadRow(): HTMLTableRowElement { 58 | let row = document.createElement("tr"); 59 | row.innerHTML = "TypeURL PatternGraphQL FilterColorPreview"; 60 | return row; 61 | } 62 | 63 | function tableRow(customRule: CustomDisplayRule, index: number): HTMLTableRowElement { 64 | let row = document.createElement("tr"); 65 | let preview = document.createElement("span"); 66 | preview.innerHTML = stringOptions.get(OptionKey.checkMark)!; 67 | preview.classList.add("stashCheckerSymbol"); 68 | preview.classList.add("stashCheckerPreview"); 69 | preview.style.color = customRule.display.color; 70 | let previewElement = document.createElement("td"); 71 | previewElement.classList.add("center"); 72 | previewElement.append(preview) 73 | 74 | row.append( 75 | plainCell(customRule.target), 76 | plainCell(customRule.pattern), 77 | plainCell(customRule.filter), 78 | plainCell(customRule.display.color), 79 | previewElement, 80 | editButtonCell(index), 81 | deleteButtonCell(index) 82 | ); 83 | return row; 84 | } 85 | 86 | function plainCell(innerHtml: string): HTMLTableCellElement { 87 | let cell = document.createElement("td"); 88 | cell.innerHTML = innerHtml 89 | return cell; 90 | } 91 | 92 | function editButtonCell(index: number): HTMLTableCellElement { 93 | let cell = document.createElement("td"); 94 | let button = buttonPrimary("Edit", editRuleListener) 95 | button.setAttribute("data-index", index.toString()) 96 | cell.append(button) 97 | return cell; 98 | } 99 | 100 | function deleteButtonCell(index: number): HTMLTableCellElement { 101 | let cell = document.createElement("td"); 102 | let button = buttonDanger("Delete", deleteRuleListener) 103 | button.setAttribute("data-index", index.toString()) 104 | cell.append(button) 105 | return cell; 106 | } 107 | 108 | async function addRuleListener() { 109 | let newRule = { 110 | target: Target.Scene, 111 | pattern: "*://stashdb.org/*", 112 | filter: "organized:true", 113 | display: { color: "blue" } 114 | }; 115 | 116 | customDisplayRules.push(newRule) 117 | await populateCustomRulesTable(document.querySelector("#stashChecker-displayRules")!); 118 | } 119 | 120 | async function deleteRuleListener(this: HTMLButtonElement) { 121 | let index = parseInt(this.getAttribute("data-index")!); 122 | customDisplayRules.splice(index, 1); 123 | void setValue(StorageKey.CustomDisplayRules, customDisplayRules); 124 | await populateCustomRulesTable(document.querySelector("#stashChecker-displayRules")!); 125 | } 126 | 127 | async function editRuleListener(this: HTMLButtonElement) { 128 | let index = parseInt(this.getAttribute("data-index")!); 129 | console.debug(`Editing rule ${index}`) 130 | let oldRule: CustomDisplayRule = customDisplayRules[index]; 131 | 132 | let target = prompt(`Target (${Object.values(Target).join(", ")}):`, oldRule.target)?.trim() ?? oldRule.target 133 | let pattern = prompt("URL Pattern:", oldRule.pattern)?.trim() ?? oldRule.pattern 134 | let filter = prompt("GraphQL Filter:", oldRule.filter)?.trim() ?? oldRule.filter 135 | let color = prompt("Color (css):", oldRule.display.color)?.trim() ?? oldRule.display.color 136 | 137 | customDisplayRules[index] = { 138 | target: target as Target, 139 | pattern: pattern, 140 | filter: filter, 141 | display: { color: color } 142 | }; 143 | void setValue(StorageKey.CustomDisplayRules, customDisplayRules); 144 | await populateCustomRulesTable(document.querySelector("#stashChecker-displayRules")!); 145 | } 146 | -------------------------------------------------------------------------------- /src/settings/endpoints.ts: -------------------------------------------------------------------------------- 1 | import {getValue, setValue, StorageKey} from "./storage"; 2 | import {request} from "../request"; 3 | import {StashEndpoint} from "../dataTypes"; 4 | import {buttonDanger, buttonPrimary, getSettingsSection, newSettingsSection} from "./settings"; 5 | 6 | const defaultData: StashEndpoint[] = [{ 7 | name: "Localhost", 8 | url: "http://localhost:9999/graphql", 9 | key: "", 10 | }]; 11 | 12 | export const stashEndpoints: StashEndpoint[] = await getValue(StorageKey.StashEndpoints, defaultData); 13 | 14 | export async function initEndpointSettings() { 15 | let description = "The GraphQL endpoint URL can be generated by appending '/graphql' to your Stash base URL. The API key can be found on your security settings page. Leave the field empty, if none is required." 16 | let endpointSection = newSettingsSection("endpoints", "Stash Endpoints", description); 17 | 18 | await updateEndpoints(endpointSection); 19 | } 20 | 21 | async function updateEndpoints(container: Element) { 22 | let endpointList = stashEndpoints.map((endpoint: StashEndpoint, index: number) => { 23 | let div = document.createElement("div"); 24 | div.classList.add("stashChecker", "endpoint"); 25 | div.innerHTML = `

${endpoint.name}

${endpoint.url}

` 26 | getVersion(endpoint, div.querySelector("h3")!) 27 | 28 | let editButton = buttonPrimary("Edit", editEndpointListener) 29 | editButton.setAttribute("data-index", index.toString()); 30 | div.append(editButton); 31 | 32 | let deleteButton = buttonDanger("Delete", deleteEndpointListener); 33 | deleteButton.setAttribute("data-index", index.toString()); 34 | div.append(deleteButton); 35 | 36 | return div; 37 | }); 38 | // Add button dummy endpoint 39 | let div = document.createElement("div"); 40 | div.classList.add("stashChecker", "endpoint"); 41 | div.innerHTML = "
" 42 | div.append( 43 | buttonPrimary("Add", addEndpointListener) 44 | ); 45 | endpointList.push(div) 46 | 47 | container.replaceChildren(...endpointList) 48 | } 49 | 50 | async function addEndpointListener(this: HTMLButtonElement) { 51 | let newEndpoint: StashEndpoint = { 52 | name: prompt("Name:")?.trim()?? "", 53 | url: prompt("URL:")?.trim()?? "", 54 | key: prompt("API Key:")?.trim()?? "", 55 | }; 56 | stashEndpoints.push(newEndpoint); 57 | void setValue(StorageKey.StashEndpoints, stashEndpoints); 58 | await updateEndpoints(getSettingsSection("endpoints")!); 59 | } 60 | 61 | async function editEndpointListener(this: HTMLButtonElement) { 62 | let index = parseInt(this.getAttribute("data-index")!); 63 | let oldEndpoint: StashEndpoint = stashEndpoints[index]; 64 | 65 | stashEndpoints[index] = { 66 | name: prompt("Name:", oldEndpoint.name)?.trim() ?? oldEndpoint.name, 67 | url: prompt("URL:", oldEndpoint.url)?.trim() ?? oldEndpoint.url, 68 | key: prompt("API Key:", oldEndpoint.key)?.trim() ?? oldEndpoint.key, 69 | }; 70 | void setValue(StorageKey.StashEndpoints, stashEndpoints); 71 | await updateEndpoints(getSettingsSection("endpoints")!); 72 | } 73 | 74 | async function deleteEndpointListener(this: HTMLButtonElement) { 75 | let index = parseInt(this.getAttribute("data-index")!); 76 | stashEndpoints.splice(index, 1); 77 | void setValue(StorageKey.StashEndpoints, stashEndpoints); 78 | await updateEndpoints(getSettingsSection("endpoints")!); 79 | } 80 | 81 | async function getVersion(endpoint: StashEndpoint, element: HTMLElement) { 82 | let resolve = (data: any) => { 83 | element.innerHTML += ` (${data.version})` 84 | } 85 | let reject = (message?: string) => { 86 | let explanation = "no connection"; 87 | if (message) explanation = message.length < 30 ? message?.trim() : "wrong URL" 88 | element.innerHTML += ` (${explanation})` 89 | } 90 | await request(endpoint, "version{version}") 91 | .then(resolve).catch(reject) 92 | } 93 | -------------------------------------------------------------------------------- /src/settings/general.ts: -------------------------------------------------------------------------------- 1 | import {buttonDanger, getSettingsSection, newSettingsSection} from "./settings"; 2 | import {getValue, setValue, StorageKey} from "./storage"; 3 | import {Theme} from "../dataTypes"; 4 | 5 | export enum OptionKey { 6 | showCheckMark = "showCheckMark", 7 | showCrossMark = "showCrossMark", 8 | showTags = "showTags", 9 | showFiles = "showFiles", 10 | checkMark = "checkMark", 11 | crossMark = "crossMark", 12 | warningMark = "warningMark", 13 | theme = "theme", 14 | } 15 | 16 | const defaultBooleanOptions = new Map([ 17 | [OptionKey.showCheckMark, true], 18 | [OptionKey.showCrossMark, true], 19 | [OptionKey.showTags, true], 20 | [OptionKey.showFiles, true], 21 | ]); 22 | 23 | const defaultStringOptions = new Map([ 24 | [OptionKey.checkMark, "✓"], 25 | [OptionKey.crossMark, "✗"], 26 | [OptionKey.warningMark, "!"], 27 | [OptionKey.theme, Theme.Device], 28 | ]); 29 | 30 | export const booleanOptions: Map = await getValue(StorageKey.BooleanOptions, defaultBooleanOptions) 31 | export const stringOptions: Map = await getValue(StorageKey.StringOptions, defaultStringOptions) 32 | 33 | export function initGeneralSettings() { 34 | let generalSection = newSettingsSection("general", "General") 35 | populateGeneralSection(generalSection) 36 | } 37 | 38 | function populateGeneralSection(generalSection: HTMLElement) { 39 | let symbolSettings = fieldSet("symbol-settings", "Symbol"); 40 | symbolSettings.append( 41 | checkBox(OptionKey.showCheckMark, "Show check mark"), 42 | checkBox(OptionKey.showCrossMark, "Show cross mark"), 43 | charBox(OptionKey.checkMark, "Check mark"), 44 | charBox(OptionKey.warningMark, "Duplicate mark"), 45 | charBox(OptionKey.crossMark, "Cross mark"), 46 | ); 47 | generalSection.appendChild(symbolSettings); 48 | 49 | let tooltipSettings = fieldSet("tooltip-settings", "Tooltip"); 50 | tooltipSettings.append( 51 | checkBox(OptionKey.showTags, "Show tags"), 52 | checkBox(OptionKey.showFiles, "Show files"), 53 | selectMenu(OptionKey.theme, "Theme", [Theme.Light, Theme.Dark, Theme.Device]), 54 | ); 55 | generalSection.appendChild(tooltipSettings); 56 | 57 | let defaultButton = fieldSet("default-button", "Default Settings"); 58 | let div = document.createElement("div") 59 | div.classList.add("option") 60 | div.appendChild(buttonDanger("Reset", resetToDefault)) 61 | defaultButton.append(div); 62 | generalSection.appendChild(defaultButton); 63 | } 64 | 65 | function fieldSet(id: string, label: string) { 66 | let fieldSet = document.getElementById(`stashChecker-fieldset-${id}`) ?? document.createElement("fieldset") 67 | fieldSet.id = `stashChecker-fieldset-${id}` 68 | fieldSet.innerHTML = `${label}`; 69 | return fieldSet 70 | } 71 | 72 | function resetToDefault() { 73 | defaultBooleanOptions.forEach((value, key) => booleanOptions.set(key, value)); 74 | void setValue(StorageKey.BooleanOptions, booleanOptions) 75 | defaultStringOptions.forEach((value, key) => stringOptions.set(key, value)); 76 | void setValue(StorageKey.StringOptions, stringOptions) 77 | let generalSection = getSettingsSection("general")! 78 | populateGeneralSection(generalSection) 79 | } 80 | 81 | function checkBox(key: OptionKey, label: string): HTMLElement { 82 | let div = document.createElement("div") 83 | div.classList.add("option") 84 | 85 | let inputElement = document.createElement("input") 86 | inputElement.id = `stashChecker-checkBox-${key}` 87 | inputElement.name = key 88 | inputElement.type = "checkbox" 89 | inputElement.defaultChecked = booleanOptions.get(key) ?? defaultBooleanOptions.get(key) ?? false 90 | inputElement.addEventListener("input", () => { 91 | booleanOptions.set(key, inputElement.checked) 92 | void setValue(StorageKey.BooleanOptions, booleanOptions) 93 | }); 94 | 95 | let labelElement: HTMLLabelElement = document.createElement("label") 96 | labelElement.htmlFor = inputElement.id 97 | labelElement.innerHTML = label 98 | 99 | div.appendChild(labelElement) 100 | div.appendChild(inputElement) 101 | return div 102 | } 103 | 104 | function charBox(key: OptionKey, label: string): HTMLElement { 105 | let div = document.createElement("div") 106 | div.classList.add("option") 107 | 108 | let inputElement = document.createElement("input") 109 | inputElement.id = `stashChecker-textBox-${key}` 110 | inputElement.name = key 111 | inputElement.type = "text" 112 | inputElement.size = 2 113 | inputElement.defaultValue = stringOptions.get(key) ?? defaultStringOptions.get(key) ?? "" 114 | inputElement.addEventListener("input", () => { 115 | stringOptions.set(key, inputElement.value) 116 | void setValue(StorageKey.StringOptions, stringOptions) 117 | }); 118 | 119 | let labelElement: HTMLLabelElement = document.createElement("label") 120 | labelElement.htmlFor = inputElement.id 121 | labelElement.innerHTML = label 122 | 123 | div.appendChild(labelElement) 124 | div.appendChild(inputElement) 125 | return div 126 | } 127 | 128 | function selectMenu(key: OptionKey, label: string, options: string[]): HTMLElement { 129 | let div = document.createElement("div") 130 | div.classList.add("option") 131 | 132 | let labelElement: HTMLLabelElement = document.createElement("label") 133 | labelElement.htmlFor = `stashChecker-dropdown-${key}` 134 | labelElement.innerHTML = label 135 | 136 | let selectElement = document.createElement("select") 137 | selectElement.id = `stashChecker-dropdown-${key}` 138 | selectElement.name = key 139 | 140 | // Set the currently selected option based on saved values 141 | let currentSelection = stringOptions.get(key) ?? defaultStringOptions.get(key) ?? options[0] 142 | options.forEach(option => { 143 | let optionElement = document.createElement("option") 144 | optionElement.value = option 145 | optionElement.innerHTML = option 146 | if (option === currentSelection) { 147 | optionElement.selected = true 148 | } 149 | selectElement.appendChild(optionElement) 150 | }) 151 | 152 | selectElement.addEventListener("change", () => { 153 | stringOptions.set(key, selectElement.value) 154 | void setValue(StorageKey.StringOptions, stringOptions) 155 | }); 156 | 157 | div.appendChild(labelElement) 158 | div.appendChild(selectElement) 159 | return div 160 | } -------------------------------------------------------------------------------- /src/settings/menu.ts: -------------------------------------------------------------------------------- 1 | import {openSettingsWindow} from "./settings"; 2 | import {deleteValue, getValue, setValue} from "./storage"; 3 | 4 | const BLOCKED_SITE_KEY = `blocked_${window.location.host}`.replace(/[.\-]/, "_"); 5 | 6 | export async function initMenu() { 7 | GM.registerMenuCommand("Settings", openSettingsWindow, "s"); 8 | 9 | if (await isSiteBlocked()) { 10 | GM.registerMenuCommand(`Activate for ${window.location.host}`, unblockSite, "a"); 11 | } else { 12 | GM.registerMenuCommand(`Deactivate for ${window.location.host}`, blockSite, "d"); 13 | } 14 | } 15 | 16 | export async function isSiteBlocked(): Promise { 17 | return await getValue(BLOCKED_SITE_KEY, false); 18 | } 19 | 20 | async function blockSite() { 21 | await setValue(BLOCKED_SITE_KEY, true); 22 | window.location.reload(); 23 | } 24 | 25 | async function unblockSite() { 26 | await deleteValue(BLOCKED_SITE_KEY); 27 | window.location.reload(); 28 | } 29 | -------------------------------------------------------------------------------- /src/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import {clearObservers} from "../observer"; 2 | import {clearSymbols} from "../tooltip/tooltip"; 3 | import {runStashChecker} from "../stashChecker"; 4 | import {updateStatistics} from "./statistics"; 5 | import {setTheme} from "../style/theme"; 6 | 7 | export function initSettingsWindow() { 8 | let settingsModal = document.createElement("div"); 9 | settingsModal.id = "stashChecker-settingsModal"; 10 | settingsModal.style.display = "none"; 11 | settingsModal.classList.add("stashChecker", "modal"); 12 | settingsModal.addEventListener("click", closeSettingsWindow); 13 | 14 | let settings = document.createElement("div"); 15 | settings.id = "stashChecker-settings" 16 | settings.classList.add("stashChecker", "settings"); 17 | settingsModal.append(settings); 18 | document.body.append(settingsModal); 19 | } 20 | 21 | export function newSettingsSection(id: string, title: string, description?: string): HTMLDivElement { 22 | let section = document.createElement("div"); 23 | section.id = `stashChecker-settingsSection-${id}` 24 | section.classList.add("stashChecker", "settingsSection"); 25 | getSettings().append(section) 26 | 27 | let heading = document.createElement("h2") 28 | heading.classList.add("stashChecker", "heading"); 29 | heading.innerHTML = title; 30 | section.append(heading); 31 | 32 | if (description) { 33 | let text = document.createElement("p"); 34 | text.classList.add("stashChecker", "sub-heading"); 35 | text.innerHTML = description; 36 | section.append(text); 37 | } 38 | 39 | let body = document.createElement("div"); 40 | body.id = `stashChecker-settingsSectionBody-${id}` 41 | body.classList.add("stashChecker", "settingsSectionBody"); 42 | section.append(body) 43 | 44 | return body 45 | } 46 | 47 | function getSettings(): HTMLElement { 48 | return document.getElementById("stashChecker-settings")!; 49 | } 50 | 51 | export function getSettingsSection(id: string): HTMLElement | null { 52 | return document.getElementById(`stashChecker-settingsSectionBody-${id}`); 53 | } 54 | 55 | export function openSettingsWindow() { 56 | let settingsModal = document.getElementById("stashChecker-settingsModal"); 57 | if (settingsModal?.style?.display) { 58 | updateStatistics() 59 | settingsModal.style.display = "initial"; 60 | } 61 | } 62 | 63 | function closeSettingsWindow(this: HTMLElement, event: MouseEvent) { 64 | if (event.target === this) { 65 | this.style.display = "none"; 66 | clearObservers() 67 | clearSymbols() 68 | setTheme() 69 | void runStashChecker() 70 | } 71 | } 72 | 73 | export function buttonPrimary(label: string, listener: (this: HTMLButtonElement, ev: MouseEvent) => any): HTMLElement { 74 | let button = document.createElement("button"); 75 | button.classList.add("stashChecker", "btn", "btn-primary"); 76 | button.addEventListener("click", listener); 77 | button.innerHTML = label; 78 | return button 79 | } 80 | 81 | export function buttonDanger(label: string, listener: (this: HTMLButtonElement, ev: MouseEvent) => any): HTMLElement { 82 | let button = document.createElement("button"); 83 | button.classList.add("stashChecker", "btn", "btn-danger"); 84 | button.addEventListener("click", listener); 85 | button.innerHTML = label; 86 | return button 87 | } -------------------------------------------------------------------------------- /src/settings/statistics.ts: -------------------------------------------------------------------------------- 1 | import {readablePlural, StashSymbol, Target} from "../dataTypes"; 2 | import {getSettingsSection, newSettingsSection} from "./settings"; 3 | 4 | 5 | export function initStatistics() { 6 | newSettingsSection("statistics", "Statistics"); 7 | } 8 | 9 | export function updateStatistics() { 10 | let statisticsSection = getSettingsSection("statistics")!; 11 | let targets = [Target.Scene, Target.Movie, Target.Gallery, Target.Performer, Target.Studio, Target.Tag] 12 | let string = targets.flatMap(target => { 13 | let s = statistics(target) 14 | return s ? [s] : [] 15 | }).join("
") 16 | let span = document.createElement("span") 17 | span.innerHTML = string 18 | statisticsSection.replaceChildren(span); 19 | } 20 | 21 | function statistics(target: Target): string | null { 22 | let count = symbolCount(target) 23 | let string = `Matched ${symbolCount(target, [StashSymbol.Check, StashSymbol.Warning])} out of ${count} ${readablePlural(target)}` 24 | return (count > 0) ? string : null 25 | } 26 | 27 | function symbolCount(target: Target | undefined = undefined, symbol: StashSymbol[] | undefined = undefined): number { 28 | let symbols = Array.from(document.querySelectorAll(":not(.stashCheckerPreview).stashCheckerSymbol")) 29 | if (target) { 30 | symbols = symbols.filter(element => element.getAttribute("data-target") == target); 31 | } 32 | if (symbol) { 33 | symbols = symbols.filter(element => symbol.map(s => s.toString()).includes(element.getAttribute("data-symbol")!)); 34 | } 35 | return symbols.length 36 | } 37 | -------------------------------------------------------------------------------- /src/settings/storage.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum StorageKey { 3 | BooleanOptions = "booleanOptions", 4 | CustomDisplayRules = "customDisplayRules", 5 | StashEndpoints = "stashEndpoints", 6 | StringOptions = "stringOptions", 7 | } 8 | 9 | /** 10 | * Get value of type T from storage. Deletes stored key-value pair, if it fails to parse the value. 11 | * 12 | * Implementation of storage differs between userscript and browser extension. 13 | */ 14 | export async function getValue(key: string, defaultValue: T): Promise { 15 | const text = await GM.getValue(key, undefined); 16 | try { 17 | if (text === undefined) { 18 | return Promise.resolve(defaultValue); 19 | } else { 20 | return Promise.resolve(JSON.parse(text, reviver)); 21 | } 22 | } catch (e: any) { 23 | console.warn("Failed to parse stored value. Delete stored key-value pair.") 24 | await deleteValue(key); 25 | return Promise.resolve(defaultValue); 26 | } 27 | } 28 | 29 | /** 30 | * Write value of type T to storage. 31 | * 32 | * Implementation of storage differs between userscript and browser extension. 33 | */ 34 | export async function setValue(key: string, value: T): Promise { 35 | return GM.setValue(key, JSON.stringify(value, replacer)); 36 | } 37 | 38 | /** 39 | * Delete key-value pair from storage. 40 | * 41 | * Implementation of storage differs between userscript and browser extension. 42 | */ 43 | export async function deleteValue(key: string): Promise { 44 | return GM.deleteValue(key); 45 | } 46 | 47 | function replacer(key: string, value: any) { 48 | if(value instanceof Map) { 49 | return { 50 | dataType: 'Map', 51 | value: Array.from(value.entries()), // or with spread: value: [...value] 52 | }; 53 | } else { 54 | return value; 55 | } 56 | } 57 | 58 | function reviver(key: string, value: any) { 59 | if(typeof value === 'object' && value !== null) { 60 | if (value.dataType === 'Map') { 61 | return new Map(value.value); 62 | } 63 | } 64 | return value; 65 | } 66 | -------------------------------------------------------------------------------- /src/stashChecker.ts: -------------------------------------------------------------------------------- 1 | import {check} from "./check"; 2 | import {CheckOptions, Target} from "./dataTypes"; 3 | import {allText, capitalized, firstText, hasKana, hasKanji} from "./utils"; 4 | import {isSiteBlocked} from "./settings/menu"; 5 | 6 | export async function runStashChecker() { 7 | // Stop, if site block is configured 8 | if (await isSiteBlocked()) { 9 | console.info("Userscript is deactivated for this site. Activate in userscript menu."); 10 | return; 11 | } 12 | 13 | console.info("Running Stash Checker") 14 | let currentSite = () => window.location.href 15 | let closestUrl = (e: Element) => e.closest("a")?.href 16 | 17 | switch (window.location.host) { 18 | case "www.iwara.tv": { 19 | // TODO translate to graphql filter 20 | //(d: any) => d.files.some((f: any) => f.path.endsWith("_Source.mp4")) ? "green" : "blue" 21 | // Video code in the URL 22 | let codeRegex = /(?<=video\/)([a-zA-Z0-9]+)(?=\/|$)/ 23 | // Cut URL after code off 24 | let prepareUrl = (url: String | undefined) => { 25 | let match = url?.match(codeRegex) 26 | let end = (match?.index && match?.[0]?.length) ? match?.index + match?.[0]?.length : match?.index 27 | return url?.substring(0, end) 28 | } 29 | 30 | check(Target.Scene, ".page-video__details > .text--h1", { 31 | observe: true, 32 | urlSelector: _ => prepareUrl(currentSite()), 33 | codeSelector: () => window.location.pathname.match(codeRegex)?.[0] 34 | }); 35 | check(Target.Scene, "a.videoTeaser__title", { 36 | observe: true, 37 | urlSelector: e => prepareUrl(closestUrl(e)), 38 | codeSelector: (e: Element) => e.getAttribute("href")?.match(codeRegex)?.[0] 39 | }); 40 | break; 41 | } 42 | case "oreno3d.com": { 43 | //(d: any) => d.files.some((f: any) => f.path.endsWith("_Source.mp4")) ? "green" : "blue" 44 | check(Target.Scene, "h1.video-h1", {urlSelector: currentSite, titleSelector: null}); 45 | check(Target.Scene, "a h2.box-h2", {titleSelector: null}); 46 | break; 47 | } 48 | case "erommdtube.com": { 49 | //(d: any) => d.files.some((f: any) => f.path.endsWith("_Source.mp4")) ? "green" : "blue" 50 | check(Target.Scene, "h1.show__h1", {urlSelector: currentSite, titleSelector: null}); 51 | check(Target.Scene, "h2.main__list-title", {titleSelector: null}); 52 | break; 53 | } 54 | case "coomer.su": 55 | case "kemono.su": { 56 | check(Target.Scene, "h1.post__title", {urlSelector: currentSite, titleSelector: null}); 57 | check(Target.Scene, ".post-card > a[href*='/post/']", {titleSelector: null}); 58 | break; 59 | } 60 | case "adultanime.dbsearch.net": { 61 | if (document.querySelector("article > section[id='info-table']") !== null) { 62 | check(Target.Scene, "div[id='main-inner'] > article > h2", { 63 | urlSelector: currentSite, 64 | codeSelector: _ => document.evaluate("//dt[text()='規格品番']/following-sibling::dd[1]/p/text()", document, null, XPathResult.STRING_TYPE, null)?.stringValue?.trim() 65 | }); 66 | } 67 | check(Target.Scene, "div.item-info > :is(h4, h5) > a"); 68 | break; 69 | } 70 | case "xcity.jp": { 71 | check(Target.Scene, "#program_detail_title", { 72 | urlSelector: currentSite, 73 | codeSelector: _ => document.getElementById("hinban")?.textContent 74 | }); 75 | check(Target.Scene, ".x-itemBox", { 76 | observe: true, 77 | displaySelector: e => e.querySelector(".x-itemBox-title"), 78 | urlSelector: e => e.querySelector("a")?.href?.split("&")?.[0], 79 | titleSelector: e => e.querySelector("a")?.title 80 | }); 81 | check(Target.Performer, "#avidolDetails", { 82 | urlSelector: _ => currentSite().split(/[?&]/)[0], 83 | nameSelector: e => e.querySelector(".photo img")?.getAttribute("alt") ?? firstText(e) 84 | }); 85 | check(Target.Performer, "a[href^='/idol/detail/'][href$='/']", {observe: true}); 86 | break; 87 | } 88 | case "xslist.org": { 89 | check(Target.Performer, "span[itemprop='name']", {urlSelector: currentSite}); 90 | check(Target.Performer, "a[href*='/model/']"); 91 | check(Target.Scene, "table#movices td > strong", { 92 | urlSelector: null, 93 | codeSelector: e => e.textContent?.trim(), 94 | titleSelector: null, 95 | }); 96 | break; 97 | } 98 | case "warashi-asian-pornstars.fr": { 99 | let nameSelector = (e: Element) => allText(e) 100 | .flatMap(s => s.split(" ")) 101 | .map(s => s.trim()) 102 | .filter(s => s && !hasKanji(s) && !hasKana(s)) 103 | .map(s => capitalized(s)) 104 | .join(" "); 105 | 106 | check(Target.Performer, "#pornostar-profil [itemprop='name']", {urlSelector: currentSite, nameSelector}); 107 | check(Target.Performer, "figcaption a[href*='/s-2-0/'], figcaption a[href*='/s-3-0/']", { 108 | displaySelector: e => Array(null, "(read more)", "(lire la suite)").includes(e.textContent) ? null : e, 109 | nameSelector 110 | }); 111 | break; 112 | } 113 | case "www.animecharactersdatabase.com": 114 | check(Target.Performer, "a[href*='characters.php']:not([href*='_']):not([href*='series'])"); 115 | break; 116 | case "www.iafd.com": { 117 | if (window.location.pathname.startsWith("/person.rme/perfid=")) { 118 | check(Target.Performer, "h1", {urlSelector: currentSite}); 119 | } else if (window.location.pathname.startsWith("/title.rme/id=")) { 120 | check(Target.Scene, "h1", {urlSelector: currentSite}); 121 | } 122 | check(Target.Performer, "a[href*='/person.rme/perfid=']"); 123 | check(Target.Scene, "a[href*='/title.rme/id=']"); 124 | check(Target.Studio, "a[href*='/studio.rme/studio=']"); 125 | break; 126 | } 127 | case "javdb.com": { 128 | check(Target.Scene, ".video-detail > h2", { 129 | urlSelector: currentSite, 130 | titleSelector: e => e.querySelector("strong.current-title")?.textContent?.trim(), 131 | codeSelector: _ => { 132 | let xpath = document.evaluate("//div/strong[text()='ID:']/following-sibling::span[1]//text()", document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null) 133 | let first = xpath.iterateNext()?.textContent 134 | let second = xpath.iterateNext()?.textContent 135 | return (first && second) ? first + second : first 136 | }, 137 | }); 138 | if (window.location.pathname.startsWith("/v/")) { 139 | check(Target.Scene, "a[href^='/v/'] > .video-number", { 140 | titleSelector: e => e.parentElement?.title?.trim(), 141 | codeSelector: e => e.textContent?.trim(), 142 | }) 143 | } else { 144 | check(Target.Scene, "a[href^='/v/'] > .video-title", { 145 | titleSelector: e => e.parentElement?.title?.trim(), 146 | codeSelector: e => e.querySelector("strong")?.textContent?.trim(), 147 | }) 148 | } 149 | break; 150 | } 151 | case "theporndb.net": { 152 | let stashIdSelector = (_: Element) => document.querySelector("div[name='UUID'] > div > div.flex")?.textContent?.trim(); 153 | // Alternative endpoint url. Query both the default and this one. 154 | let stashIdEndpoint = "https://api.theporndb.net/graphql"; 155 | check(Target.Performer, "div.pl-4 > h2", { 156 | observe: true, 157 | displaySelector: e => window.location.pathname.startsWith("/performers/") ? e : null, 158 | urlSelector: currentSite, 159 | stashIdSelector 160 | }); 161 | check(Target.Performer, "div.pl-4 > h2", { 162 | observe: true, 163 | displaySelector: e => window.location.pathname.startsWith("/performers/") ? e : null, 164 | urlSelector: null, 165 | nameSelector: null, 166 | stashIdSelector, 167 | stashIdEndpoint 168 | }); 169 | check(Target.Scene, "div.flex.justify-between > h2", { 170 | observe: true, 171 | displaySelector: e => window.location.pathname.startsWith("/scenes/") || window.location.pathname.startsWith("/jav/") ? e : null, 172 | urlSelector: currentSite, 173 | stashIdSelector 174 | }); 175 | check(Target.Scene, "div.flex.justify-between > h2", { 176 | observe: true, 177 | displaySelector: e => window.location.pathname.startsWith("/scenes/") || window.location.pathname.startsWith("/jav/") ? e : null, 178 | urlSelector: null, 179 | titleSelector: null, 180 | stashIdSelector, 181 | stashIdEndpoint 182 | }); 183 | check(Target.Movie, "div.flex.justify-between > h2", { 184 | observe: true, 185 | displaySelector: e => window.location.pathname.startsWith("/movies/") ? e : null, 186 | urlSelector: currentSite, 187 | stashIdSelector 188 | }); 189 | check(Target.Movie, "div.flex.justify-between > h2", { 190 | observe: true, 191 | displaySelector: e => window.location.pathname.startsWith("/movies/") ? e : null, 192 | urlSelector: null, 193 | nameSelector: null, 194 | stashIdSelector, 195 | stashIdEndpoint 196 | }); 197 | check(Target.Performer, "a[href^='https://theporndb.net/performers/']", {observe: true}); 198 | check(Target.Scene, "a[href^='https://theporndb.net/scenes/'], a[href^='https://theporndb.net/jav/']", {observe: true}); 199 | check(Target.Movie, "a[href^='https://theporndb.net/movies/']", {observe: true}); 200 | break; 201 | } 202 | case "www.javlibrary.com": { 203 | check(Target.Scene, "div#video_title", { 204 | urlSelector: _ => currentSite().replace("videoreviews.php", "").replace(/&.*$/, ""), 205 | codeSelector: _ => document.querySelector("div#video_id td.text")?.textContent?.trim(), 206 | titleSelector: _ => document.querySelector("div#video_id td.text")?.textContent?.trim(), 207 | }); 208 | // generic video links as list view / thumbnail view 209 | let searchParams = new URLSearchParams(window.location.search) 210 | if (searchParams.has("list")) { 211 | check(Target.Scene, ".video a[href^='./?v=jav'], .title a[href^='./?v=jav']", { 212 | observe: true, 213 | urlSelector: e => closestUrl(e)?.replace(/&.*$/, ""), 214 | codeSelector: e => e.getAttribute("title")?.split(" ", 1)?.[0], 215 | titleSelector: e => e.getAttribute("title")?.replace(/^\S*\s/, ""), 216 | }); 217 | } else { 218 | check(Target.Scene, ".video a[href^='./?v=jav']", { 219 | observe: true, 220 | urlSelector: e => closestUrl(e)?.replace(/&.*$/, ""), 221 | codeSelector: e => e.querySelector("div.id")?.textContent?.trim(), 222 | titleSelector: e => e.querySelector("div.title")?.textContent?.trim() ?? firstText(e), 223 | }); 224 | } 225 | // best reviews 226 | check(Target.Scene, ".comment strong > a[href^='videoreviews.php?v=jav']", { 227 | urlSelector: e => closestUrl(e)?.replace("videoreviews.php", "").replace(/&.*$/, ""), 228 | codeSelector: e => firstText(e)?.split(" ")?.[0], 229 | titleSelector: e => firstText(e)?.replace(/^\S*\s/, ""), 230 | }); 231 | break; 232 | } 233 | case "r18.dev": { 234 | check(Target.Scene, "#video-info > #title", { 235 | observe: true, 236 | urlSelector: currentSite, 237 | codeSelector: _ => firstText(document.querySelector("#dvd-id")), 238 | }); 239 | check(Target.Scene, ".video-label > a[href*='/movies/detail/']", { 240 | observe: true, 241 | codeSelector: firstText, 242 | }); 243 | break; 244 | } 245 | case "www.manyvids.com": { 246 | check(Target.Studio, "[class^='ProfileAboutMeUI_stageName_']", { 247 | observe: true, 248 | urlSelector: currentSite, 249 | }); 250 | check(Target.Studio, "[class^='VideoProfileCard_actions_'] a[href^='/Profile/'], [class^='CardCreatorHeaderUI_creatorInfo_'] a[href^='/Profile/']", { 251 | observe: true, 252 | urlSelector: e => closestUrl(e)?.replace(/Store\/Videos$/, ""), 253 | }); 254 | check(Target.Scene, "h1[class^='VideoMetaInfo_title_']", { 255 | observe: true, 256 | urlSelector: currentSite, 257 | codeSelector: _ => window.location.pathname.split("/")[2] 258 | }); 259 | check(Target.Scene, "[class^='VideoCardUI_videoTitle_'] a[href^='/Video/']", { 260 | observe: true, 261 | codeSelector: e => e.getAttribute("href")?.split("/")?.[2] 262 | }); 263 | break; 264 | } 265 | case "www.minnano-av.com": { 266 | if (/actress\d{1,6}/.test(window.location.pathname)) { 267 | check(Target.Performer, "h1", { 268 | urlSelector: _ => currentSite().split("?")[0], 269 | }); 270 | } 271 | check(Target.Performer, "a[href*='actress']:not([href*='list']):not([href*='.php']):not([href*='http'])", { 272 | urlSelector: e => closestUrl(e)?.split("?")?.[0], 273 | }); 274 | break; 275 | } 276 | case "www.indexxx.com": { 277 | check(Target.Performer, "h1[id='model-name']", {urlSelector: currentSite}); 278 | check(Target.Performer, "a[class^='modelLink'][href*='/m/'] > span", {observe: true}); 279 | break; 280 | } 281 | case "www.thenude.com": { 282 | check(Target.Performer, "span.model-name", {urlSelector: currentSite}); 283 | check(Target.Performer, "a.model-name, a.model-title, a[data-img*='/models/']", {observe: true}); 284 | break; 285 | } 286 | case "www.data18.com": { 287 | check(Target.Scene, "a[href^='https://www.data18.com/scenes/']:not([href*='#'])", { 288 | observe: true, 289 | titleSelector: e => e.getAttribute("title")?.trim() 290 | }); 291 | check(Target.Movie, "a[href^='https://www.data18.com/movies/']:not([href*='#']):not([href$='/movies/series']):not([href$='/movies/showcases'])", { 292 | observe: true, 293 | nameSelector: e => e.getAttribute("title")?.trim() 294 | }); 295 | let exclude = ":not([href*='/pairings']):not([href*='/studio']):not([href*='/virtual-reality']):not([href*='/scenes']):not([href*='/movies']):not([href*='/tags']):not([title$=' Home'])" 296 | check(Target.Performer, `a[href^='https://www.data18.com/name/']${exclude}`, {observe: true}); 297 | if (window.location.pathname === "/names/pornstars") { 298 | check(Target.Performer, `a[href^='https://www.data18.com/name/']${exclude}`, { 299 | observe: true, 300 | displaySelector: e => e.parentElement?.querySelector("div"), 301 | nameSelector: e => e.getAttribute("title") 302 | }) 303 | } 304 | break; 305 | } 306 | case "www.adultfilmdatabase.com": { 307 | check(Target.Performer, "h1.w3-opacity", { 308 | displaySelector: e => window.location.pathname.startsWith("/actor/") ? e : null, 309 | urlSelector: currentSite 310 | }); 311 | check(Target.Scene, "h1[itemprop='name']", {urlSelector: currentSite}); 312 | check(Target.Studio, "h1.w3-opacity", { 313 | displaySelector: e => window.location.pathname.startsWith("/studio/") ? e : null, 314 | urlSelector: currentSite 315 | }); 316 | check(Target.Studio, "a[href^='/studio/']", {observe: true}); 317 | check(Target.Performer, "a[href^='/actor/']", { 318 | observe: true, 319 | displaySelector: e => firstText(e) === "as performer" ? null : e 320 | }); 321 | check(Target.Scene, "a[href^='/video/']", { 322 | observe: true, 323 | titleSelector: e => e.getAttribute("title")?.trim() ?? firstText(e) 324 | }); 325 | break; 326 | } 327 | case "www.brazzers.com": { 328 | check(Target.Scene, "h2[class='sc-1b6bgon-3 iTXrhy font-secondary']", { 329 | observe: true, 330 | urlSelector: currentSite 331 | }); 332 | check(Target.Scene, "a[href*='/video/'", {observe: true}); 333 | check(Target.Performer, "h2[class='sc-ebvhsz-1 fLnSSs font-secondary']", { 334 | observe: true, 335 | urlSelector: currentSite 336 | }); 337 | check(Target.Performer, "a[href*='/pornstar/'", {observe: true}); 338 | break; 339 | } 340 | case "hobby.porn": { 341 | check(Target.Performer, "a[href*='/model/']:not([href*='modelInfo'])", { 342 | urlSelector: e => closestUrl(e)?.match(/\/model\/[^\/]+\/\d+$/)?.[0], 343 | nameSelector: e => firstText(e)?.replace("porn", "")?.replace("videos", "")?.trim() 344 | }); 345 | check(Target.Performer, "h1", { nameSelector: e => firstText(e)?.replace("Free Amateur Porn - Hobby.porn", "").split("porn videos")?.[0].trim()}); 346 | check(Target.Performer, "#tab_info > div.model-info > div > b"); 347 | check(Target.Scene, "h1[itemprop='name']", { urlSelector: currentSite }); 348 | check(Target.Scene, "div[class*='item item-video item-lozad'] a[href*='hobby.porn/video/'] div.title-holder", { observe: true }); 349 | break; 350 | } 351 | case "www.pornhub.com": { 352 | check(Target.Performer, "[class*='pcVideoListItem'] a[href*='/model/'], [class*='pcVideoListItem'] a[href*='/pornstar/']"); 353 | check(Target.Performer, "h1[itemprop='name']", { urlSelector: currentSite }); 354 | check(Target.Performer, "span.pornStarName.performerCardName, div.userCardNameBlock, span.usernameBadgesWrapper"); 355 | check(Target.Performer, "div.modelVideosTitle, div.subHeaderOverrite > h2", { 356 | urlSelector: currentSite, 357 | nameSelector: e => firstText(e)?.split("'s")?.[0].trim() 358 | }); 359 | check(Target.Studio, "[class*='pcVideoListItem'] a[href*='/channels/']"); 360 | check(Target.Studio, "[id='channelsProfile'] h1", { urlSelector: currentSite }); 361 | check(Target.Scene, "div.videoUList span.title a[href*='/view_video.php?viewkey=']", { observe: true }); 362 | check(Target.Scene, "h1.title", { urlSelector: currentSite }) 363 | check(Target.Scene, "[class*='pcVideoListItem'] span.title a[href*='/view_video.php?viewkey=']", { observe: true }) 364 | break; 365 | } 366 | case "www.clips4sale.com": { 367 | let hrefStudio = "[href^='/studio/']"; 368 | check(Target.Studio, "h1[data-testid*='studio-title']", {urlSelector: currentSite}); 369 | check(Target.Studio, `a[data-testid*='studio-link']${hrefStudio}, a[data-testid*='clip-page-clipCategory']${hrefStudio}`, {urlSelector: e => closestUrl(e)?.split("/Cat")?.[0]}); 370 | check(Target.Studio, `a[data-testid*='clip-category-link']${hrefStudio}, a[data-testid*='clip-studio']${hrefStudio}, a[data-testid*='studioAnchor']${hrefStudio}, div[data-testid*='categoryTopStores'] a${hrefStudio}`, { 371 | observe: true, 372 | urlSelector: e => closestUrl(e)?.split("/Cat")?.[0] 373 | }); 374 | if (window.location.pathname.startsWith("/clips/page/studios")) { 375 | check(Target.Studio, `a${hrefStudio}`, {observe: true}); 376 | } 377 | check(Target.Scene, "h1[data-testid*='clip-page-clipTitle']", {urlSelector: currentSite}); 378 | check(Target.Scene, `a[data-testid*='clip-link']${hrefStudio}, a[data-testid*='clipCard-titleAnchor']${hrefStudio}`, {observe: true}); 379 | break; 380 | } 381 | case "www.babepedia.com": { 382 | check(Target.Performer, "h1#babename", {urlSelector: currentSite}); 383 | check(Target.Performer, "a[href*='/babe/']", {observe: true}); 384 | break; 385 | } 386 | case "www.freeones.com": { 387 | check(Target.Performer, "a[href$='/feed'] [data-test='subject-name'], a[href$='/feed'] .profile-image + p", { 388 | urlSelector: e => closestUrl(e)?.replace(/\/feed$/, "").replace(/\/[a-z]{2}\//, "/") 389 | }); 390 | break; 391 | } 392 | case "shemalestardb.com": { 393 | check(Target.Performer, "h2[id='star-name']", {urlSelector: currentSite}); 394 | check(Target.Performer, "figcaption > a[href*='/stars/']"); 395 | break; 396 | } 397 | case "onlyfans.com": { 398 | check(Target.Performer, "div.b-username > div.g-user-name", {observe: true, urlSelector: currentSite}); 399 | check(Target.Performer, "a.b-username > div.g-user-name", {observe: true}); 400 | break; 401 | } 402 | case "fansly.com": { 403 | check(Target.Performer, "a.username-wrapper > div > span.display-name", {observe: true, urlSelector: currentSite}); 404 | check(Target.Performer, "a.username-wrapper > div > span.user-name", { 405 | urlSelector: _ => currentSite().replace("/^@/", "") 406 | }); 407 | break; 408 | } 409 | case "www.slayed.com": 410 | case "www.blacked.com": 411 | case "www.tushy.com": 412 | case "www.vixen.com": 413 | case "www.blackedraw.com": 414 | case "www.tushyraw.com": 415 | case "www.deeper.com": 416 | case "www.milfy.com": 417 | case "www.wifey.com":{ 418 | check(Target.Scene, 'a[data-test-component="TitleLink"]', {observe: true}); 419 | check(Target.Scene, 'h1[data-test-component="VideoTitle"]', {observe: true}); 420 | check(Target.Performer, "a[href*='/performers/']", {observe: true}); 421 | break; 422 | } 423 | case "www.angelslove.xxx": 424 | case "www.sensuallove.xxx": 425 | case "www.wowgirlsblog.com": 426 | case "www.ultrafilms.xxx": 427 | case "www.18onlygirlsblog.com": { 428 | check(Target.Scene, "article a header span"); 429 | check(Target.Performer, "a[href*='/actor/']"); 430 | break; 431 | } 432 | case "www.metart.com": 433 | case "www.metartx.com": 434 | case "www.sexart.com": 435 | case "www.vivthomas.com": 436 | case "www.thelifeerotic.com": 437 | case "www.straplez.com": 438 | case "www.errotica-archives.com": 439 | case "www.domai.com": 440 | case "www.goddessnudes.com": 441 | case "www.eroticbeauty.com": 442 | case "www.lovehairy.com": 443 | case "www.alsscan.com": 444 | case "www.rylskyart.com": 445 | case "www.eternaldesire.com": { 446 | check(Target.Scene, "a[href*='/movie']", {observe: true}); 447 | check(Target.Performer, "a[href*='/model/']:not([href*='/movie'])", {observe: true}); 448 | break; 449 | } 450 | case "www.pornteengirl.com": { 451 | check(Target.Performer, "a[href*='/model/']", { 452 | nameSelector: e => firstText(e)?.replace(/\([^()]*\)$/, "")?.trimEnd() 453 | }); 454 | break; 455 | } 456 | case "gayeroticvideoindex.com": { 457 | if (window.location.pathname.startsWith("/performer/")) { 458 | check(Target.Performer, "[id='data'] h1", {urlSelector: currentSite}); 459 | } else if (window.location.pathname.startsWith("/episode/")) { 460 | check(Target.Scene, "[id='data'] h1", {urlSelector: currentSite}); 461 | } else if (window.location.pathname.startsWith("/video/")) { 462 | check(Target.Movie, "[id='data'] h1", {urlSelector: currentSite}); 463 | } 464 | check(Target.Performer, "a[href*='performer/']", {observe: true}); 465 | check(Target.Scene, "a[href*='episode/']", {observe: true}); 466 | check(Target.Movie, "a[href*='video/']", {observe: true}); 467 | break; 468 | } 469 | case "pmvhaven.com": { 470 | check(Target.Scene, "h1.pl-2", { 471 | observe: true, 472 | displaySelector: e => window.location.pathname.startsWith("/video/") ? e : null, 473 | urlSelector: currentSite 474 | }); 475 | check(Target.Scene, "a[href^='/video/'] .v-card-text", {observe: true,}); 476 | check(Target.Studio, ".v-card-title", { 477 | observe: true, 478 | displaySelector: e => window.location.pathname.startsWith("/creator/") ? e : null, 479 | urlSelector: currentSite 480 | }); 481 | check(Target.Studio, "a[href^='/creator/'] .v-chip__content", {observe: true,}); 482 | break; 483 | } 484 | case "fansdb.cc": 485 | case "fansdb.xyz": 486 | case "javstash.org": 487 | case "pmvstash.org": 488 | case "stashdb.org": { 489 | // These buttons are only visible with edit permissions. 490 | let exclude = ":not(a[href$='/add']):not(a[href$='/edit']):not(a[href$='/merge']):not(a[href$='/delete'])"; 491 | let stashBoxDefault: CheckOptions = { 492 | observe: true, 493 | urlSelector: null, 494 | titleSelector: null, 495 | nameSelector: null 496 | } 497 | 498 | // noinspection Annotator 499 | function findId(string?: string): undefined | string { 500 | return string?.match(/\p{Hex}{8}-\p{Hex}{4}-\p{Hex}{4}-\p{Hex}{4}-\p{Hex}{12}/u)?.[0] 501 | } 502 | 503 | check(Target.Scene, "div.scene-info.card h3 > span", { 504 | ...stashBoxDefault, 505 | stashIdSelector: () => findId(currentSite()), 506 | }); 507 | check(Target.Scene, `a[href^='/scenes/']${exclude}, a[href^='https://${currentSite()}/scenes/']${exclude}`, { 508 | ...stashBoxDefault, 509 | stashIdSelector: (e) => findId(closestUrl(e)), 510 | }); 511 | check(Target.Performer, "div.PerformerInfo div.card-header h3 > span", { 512 | ...stashBoxDefault, 513 | stashIdSelector: () => findId(currentSite()), 514 | }); 515 | check(Target.Performer, `a[href^='/performers/']${exclude}, a[href^='https://${currentSite()}/performers/']${exclude}`, { 516 | ...stashBoxDefault, 517 | stashIdSelector: (e) => findId(closestUrl(e)), 518 | }); 519 | check(Target.Studio, ".studio-title > h3 > span", { 520 | ...stashBoxDefault, 521 | stashIdSelector: () => findId(currentSite()), 522 | }); 523 | check(Target.Studio, `a[href^='/studios/']${exclude}, a[href^='https://${currentSite()}/studios/']${exclude}`, { 524 | ...stashBoxDefault, 525 | stashIdSelector: (e) => findId(closestUrl(e)), 526 | }); 527 | // Tag by StashId isn't supported by Stash yet 528 | /*check(Target.Tag, ".MainContent > .NarrowPage h3 > span", { 529 | ...stashBoxDefault, 530 | displaySelector: e => window.location.pathname.startsWith("/tags/") ? e : null, // only on tag page 531 | stashIdSelector: () => findId(currentSite()), 532 | }); 533 | check(Target.Tag, `a[href^='/tags/']${exclude}, a[href^='https://${currentSite()}/tags/']${exclude}`, { 534 | ...stashBoxDefault, 535 | displaySelector: e => window.location.pathname === "/tags" ? e : null, // only on overview page 536 | stashIdSelector: (e) => findId(closestUrl(e)), 537 | });*/ 538 | break; 539 | } 540 | default: 541 | console.warn("No configuration for website found."); 542 | break; 543 | } 544 | } 545 | -------------------------------------------------------------------------------- /src/style/main.scss: -------------------------------------------------------------------------------- 1 | 2 | .stashChecker.tooltip { 3 | /* Floating-UI defaults */ 4 | top: 0; 5 | left: 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/style/main_important.scss: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --stash-checker-color-text: #323232; 4 | --stash-checker-color-text-light: #989898; 5 | --stash-checker-color-link-visited: #323232; 6 | --stash-checker-color-link-hover: #039; 7 | --stash-checker-color-link-active: #039; 8 | --stash-checker-color-border: #323232; 9 | --stash-checker-color-border-light: #989898; 10 | --stash-checker-color-bg: #ffffff; 11 | --stash-checker-color-card: #f2f2f2; 12 | } 13 | 14 | .stashChecker-dark-mode { 15 | --stash-checker-color-text: #e0e0e0; 16 | --stash-checker-color-text-light: #707070; 17 | --stash-checker-color-link-visited: #c7c7c7; 18 | --stash-checker-color-link-hover: #f2f2f2; 19 | --stash-checker-color-link-active: #039; 20 | --stash-checker-color-border: #5a5a5a; 21 | --stash-checker-color-border-light: #707070; 22 | --stash-checker-color-bg: #202020; 23 | --stash-checker-color-card: #464646; 24 | } 25 | 26 | .stashChecker { 27 | color: var(--stash-checker-color-text); 28 | text-align: left; 29 | font-size: medium; 30 | line-height: normal; 31 | opacity: 1; 32 | } 33 | 34 | .stashChecker.sub-heading { 35 | font-size: 0.8rem; 36 | text-align: center; 37 | margin: 0 0 0.5rem; 38 | } 39 | 40 | .stashChecker.tooltip { 41 | visibility: visible; 42 | z-index: 99999; 43 | background-color: var(--stash-checker-color-bg); 44 | border: 0.1rem solid var(--stash-checker-color-border); 45 | border-radius: 0.5rem; 46 | padding: 0.5rem; 47 | max-width: 60rem; 48 | 49 | /* Floating-UI */ 50 | position: absolute; 51 | width: max-content; 52 | } 53 | 54 | .stashChecker.file { 55 | position: relative; 56 | margin: 0.5rem; 57 | padding: 0.5rem; 58 | background-color: var(--stash-checker-color-card); 59 | } 60 | 61 | .stashChecker.tag { 62 | white-space: nowrap; 63 | line-height: 1.5rem; 64 | margin-right: 0.5rem; 65 | padding: 0 0.5rem; 66 | background-color: var(--stash-checker-color-card); 67 | border-radius: 0.5rem; 68 | } 69 | 70 | .stashChecker.modal { 71 | position: fixed; 72 | z-index: 999999; 73 | left: 0; 74 | top: 0; 75 | width: 100%; 76 | height: 100%; 77 | overflow: hidden auto; 78 | overscroll-behavior: contain; 79 | background-color: rgb(0, 0, 0); // fallback 80 | background-color: rgba(0, 0, 0, 0.4); 81 | } 82 | 83 | .stashChecker.settings { 84 | margin: 10vh auto; 85 | background-color: var(--stash-checker-color-bg); 86 | border: 0.1rem solid var(--stash-checker-color-border); 87 | border-radius: 0.5rem; 88 | padding: 0.5rem; 89 | width: fit-content; 90 | display: grid; 91 | gap: 1rem; 92 | } 93 | 94 | .stashChecker.settings .version { 95 | color: var(--stash-checker-color-text-light); 96 | font-size: 1.25rem; 97 | } 98 | 99 | .stashChecker.settingsSection { 100 | width: 50rem; 101 | } 102 | 103 | .stashChecker.settingsSectionBody { 104 | width: 100%; 105 | display: flex; 106 | flex-flow: row wrap; 107 | justify-content: flex-start; 108 | align-items: flex-start; 109 | gap: 0.5rem; 110 | } 111 | 112 | .stashChecker.endpoint { 113 | width: 100%; 114 | display: flex; 115 | flex-direction: row; 116 | justify-content: space-between; 117 | justify-items: flex-start; 118 | align-items: center; 119 | padding: 1rem; 120 | margin: 0.1rem; 121 | background-color: var(--stash-checker-color-card); 122 | } 123 | 124 | .stashChecker.endpoint > button { 125 | flex-grow: 0; 126 | margin-left: 0.5rem; 127 | } 128 | 129 | .stashChecker.endpoint > div { 130 | flex-grow: 1; 131 | } 132 | 133 | .stashChecker.endpoint > div > * { 134 | margin: 0; 135 | } 136 | 137 | .stashChecker.heading { 138 | font-size: 1.5rem; 139 | text-align: center; 140 | } 141 | 142 | .stashChecker fieldset { 143 | width: fit-content; 144 | border: 0.1rem solid var(--stash-checker-color-border-light); 145 | border-radius: 0.5rem; 146 | margin: 0.5rem 0 0.5rem 0; 147 | padding: 0.5rem; 148 | flex-grow: 1; 149 | } 150 | 151 | .stashChecker legend { 152 | float: unset; 153 | width: auto; 154 | height: auto; 155 | margin-left: 0.5rem; 156 | margin-bottom: 0; 157 | padding-left: 0.2rem; 158 | padding-right: 0.2rem; 159 | line-height: unset; 160 | font-size: unset; 161 | } 162 | 163 | .stashChecker table, 164 | .stashChecker thead, 165 | .stashChecker tbody, 166 | .stashChecker tr, 167 | .stashChecker th, 168 | .stashChecker td { 169 | border-collapse: collapse; 170 | border: 0.1rem solid var(--stash-checker-color-border); 171 | padding: 0.2rem; 172 | } 173 | 174 | .stashChecker .center { 175 | text-align: center; 176 | } 177 | 178 | .stashChecker .option { 179 | text-align: right; 180 | margin: 0.5rem; 181 | } 182 | 183 | .stashChecker .option > input { 184 | margin-left: 0.5rem; 185 | color: var(--stash-checker-color-text); 186 | background-color: var(--stash-checker-color-bg); 187 | } 188 | 189 | .stashChecker .option > select { 190 | margin-left: 0.5rem; 191 | } 192 | 193 | .stashChecker .option > label { 194 | } 195 | 196 | .stashChecker > .matchQuality { 197 | width: 0.8em; 198 | height: 0.8em; 199 | display: inline-block; 200 | border-radius: 50%; 201 | } 202 | 203 | .stashChecker.btn { 204 | display: inline-block; 205 | font-weight: 400; 206 | color: #212529; 207 | text-align: center; 208 | vertical-align: middle; 209 | user-select: none; 210 | background-color: transparent; 211 | border: 1px solid transparent; 212 | padding: .375rem .75rem; 213 | font-size: 1rem; 214 | line-height: 1.5; 215 | border-radius: .25rem; 216 | transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out; 217 | } 218 | 219 | .stashChecker.btn:not(:disabled):not(.disabled) { 220 | cursor: pointer; 221 | } 222 | 223 | .stashChecker.btn:hover { 224 | color: #212529; 225 | text-decoration: none; 226 | } 227 | 228 | .stashChecker.btn-primary { 229 | color: #fff; 230 | background-color: #137cbd; 231 | border-color: #137cbd; 232 | } 233 | 234 | .stashChecker.btn-primary:hover { 235 | color: #fff; 236 | background-color: #10659a; 237 | border-color: #0e5e8f; 238 | } 239 | 240 | .stashChecker.btn-danger { 241 | color: #fff; 242 | background-color: #db3737; 243 | border-color: #db3737; 244 | } 245 | 246 | .stashChecker.btn-danger:hover { 247 | color: #fff; 248 | background-color: #c82424; 249 | border-color: #bd2222; 250 | } 251 | 252 | .stashChecker.tooltip a:link { 253 | color: var(--stash-checker-color-text); 254 | } 255 | 256 | .stashChecker.tooltip a:visited { 257 | color: var(--stash-checker-color-link-visited); 258 | } 259 | 260 | .stashChecker.tooltip a:hover { 261 | color: var(--stash-checker-color-link-hover); 262 | } 263 | 264 | .stashChecker.tooltip a:active { 265 | color: var(--stash-checker-color-link-active); 266 | } 267 | 268 | .stashChecker.tooltip hr { 269 | margin-top: 0.5rem; 270 | margin-bottom: 0.5rem; 271 | border-color: var(--stash-checker-color-border-light); 272 | background-color: var(--stash-checker-color-border-light); 273 | } 274 | 275 | .stashChecker.tooltip hr + br { 276 | display: none; 277 | } 278 | 279 | .stashChecker.file + br { 280 | display: none; 281 | } 282 | 283 | .stashCheckerSymbol { 284 | font-size: inherit; 285 | } 286 | -------------------------------------------------------------------------------- /src/style/theme.ts: -------------------------------------------------------------------------------- 1 | import {OptionKey, stringOptions} from "../settings/general"; 2 | import {Theme} from "../dataTypes"; 3 | 4 | export function setTheme() { 5 | 6 | const osSetting = window.matchMedia("(prefers-color-scheme: dark)"); 7 | 8 | function toggleDarkMode(state: boolean | undefined) { 9 | document.documentElement.classList.toggle("stashChecker-dark-mode", state); 10 | } 11 | 12 | switch (stringOptions.get(OptionKey.theme)) { 13 | case Theme.Light: 14 | toggleDarkMode(false) 15 | break; 16 | case Theme.Dark: 17 | toggleDarkMode(true) 18 | break; 19 | case Theme.Device: 20 | default: 21 | toggleDarkMode(osSetting.matches); 22 | break; 23 | } 24 | } -------------------------------------------------------------------------------- /src/tooltip/stashQuery.ts: -------------------------------------------------------------------------------- 1 | import {entryLink, typeToString} from "../utils"; 2 | import {Target, Type} from "../dataTypes"; 3 | 4 | export interface StashQuery { 5 | endpoint: string; 6 | baseUrl: string; 7 | types: Type[]; 8 | } 9 | 10 | /** 11 | * Queries per entry and endpoint. 12 | */ 13 | export class StashQueryClass implements StashQuery { 14 | endpoint: string; 15 | baseUrl: string; 16 | types: Type[]; 17 | 18 | constructor(query: StashQuery) { 19 | this.endpoint = query.endpoint; 20 | this.baseUrl = query.baseUrl; 21 | this.types = query.types; 22 | } 23 | 24 | addTypes(types: Type[]): void { 25 | let typeSet = new Set(this.types) 26 | types.forEach(type => typeSet.add(type)); 27 | this.types = Array.from(typeSet).sort() 28 | } 29 | 30 | compareTo(query: StashQueryClass): number { 31 | return this.endpoint.localeCompare(query.endpoint) 32 | } 33 | 34 | toHtml(target: Target, id: string, numQueries: number): string { 35 | let typesString = `(Matched: ${this.types.map(type => typeToString.get(type)).join(", ")})` 36 | return `${this.matchQualityHtml(numQueries)} ${this.endpoint} ${typesString}: ${entryLink(this.baseUrl, target, id)}` 37 | } 38 | 39 | private matchQualityHtml(numQueries: number): string { 40 | let matchQuality = this.types.length / numQueries 41 | let color; 42 | if (matchQuality == 1) color = "rgb(0,100,0)"; 43 | else if (matchQuality > 0.5) color = "rgb(100,100,0)"; 44 | else color = "rgb(100,50,0)"; 45 | return ``; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/tooltip/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataField, 3 | DisplayOptions, 4 | readable, 5 | StashEndpoint, 6 | StashEntry, 7 | StashFile, 8 | StashSymbol, 9 | Target, 10 | Type 11 | } from "../dataTypes"; 12 | import {bytesToReadable, firstTextChild, secondsToReadable, typeToString} from "../utils"; 13 | import {booleanOptions, OptionKey, stringOptions} from "../settings/general"; 14 | import {StashQuery, StashQueryClass} from "./stashQuery"; 15 | import {mouseoverListener, mouseoutListener} from "./tooltipElement"; 16 | 17 | /** 18 | * find existing symbol span recursively, undefined if none available 19 | */ 20 | function getExistingSymbol(element: Element): HTMLSpanElement | undefined { 21 | if (element.getAttribute("data-type") === "stash-symbol") { 22 | return element as HTMLSpanElement; 23 | } else { 24 | return Array.from(element.childNodes) 25 | .filter(n => n.nodeType === Node.ELEMENT_NODE) 26 | .map(n => n as Element) 27 | .map(getExistingSymbol) 28 | .find(n => n); // first truthy 29 | } 30 | } 31 | 32 | export function clearSymbols() { 33 | document.querySelectorAll(":not(.stashCheckerPreview).stashCheckerSymbol") 34 | .forEach(symbol => symbol.remove()) 35 | } 36 | 37 | const propertyStrings: Map string> = new Map([ 38 | [DataField.Aliases, (aliases: any) => aliases.length === 0 ? "" : `
Aliases: ${aliases.join(", ")}`], 39 | [DataField.AliasList, (aliasList: any) => aliasList.length === 0 ? "" : `
Aliases: ${aliasList.join(", ")}`], 40 | [DataField.Birthdate, (birthdate: string) => `
Birthdate: ${birthdate}`], 41 | [DataField.BitRate, (bit_rate: any) => `    Bitrate: ${(bit_rate / 1000000).toFixed(2)}Mbit/s`], 42 | [DataField.Code, (code: string) => `
Code: ${code}`], 43 | [DataField.Date, (date: string) => `
Date: ${date}`], 44 | [DataField.Disambiguation, (disambiguation: string) => ` (${disambiguation})`], 45 | [DataField.Duration, (duration: any) => `    Duration: ${secondsToReadable(duration)}`], 46 | [DataField.Favorite, () => " ❤️"], 47 | [DataField.Files, (files: any, queries: StashQuery[], target: Target, numQueries: number) => `${files.map((file: StashFile) => formatFileData(file, queries, target, numQueries)).join("")}`], 48 | [DataField.Height, (height: any) => `x${height})`], 49 | [DataField.HeightCm, (height: any) => `
Height: ${height} cm`], 50 | [DataField.Id, (id: string, queries: StashQuery[], target: Target, numQueries: number) => `
${formatQueries(queries, target, id, numQueries)}`], 51 | [DataField.Name, (name: string) => `
Name: ${name}`], 52 | [DataField.Organized, (organized: any) => organized ? ` 📦` : ""], 53 | [DataField.Path, (path: string) => `Path: ${path}`], 54 | [DataField.Size, (size: any) => `    Size: ${bytesToReadable(size)}`], 55 | [DataField.Studio, (studio: any) => `
Studio: ${studio[DataField.Name]}`], 56 | [DataField.Tags, (tags: any) => tags.length === 0 ? "" : `
Tags: ${tags.map(formatTagPill).join("")}`], 57 | [DataField.Title, (title: string) => `
Title: ${title}`], 58 | [DataField.VideoCodec, (video_codec: any) => `
Codec: ${video_codec}`], 59 | [DataField.Width, (width: any) => ` (${width}`], 60 | ]); 61 | 62 | function formatFileData(file: StashFile, queries: StashQuery[], target: Target, numQueries: number): string { 63 | let text = Object.entries(file) 64 | .map(([key, value]: [string, any]) => value ? propertyStrings.get(key)?.(value, queries, target, numQueries) : undefined) 65 | .filter(s => s) 66 | .join("") 67 | return `
${text}
` 68 | } 69 | 70 | function formatTagPill(tag: { id: string, name: string }): string { 71 | return `${tag.name}`; 72 | } 73 | 74 | function formatQueries(queries: StashQuery[], target: Target, id: string, numQueries: number): string { 75 | return queries.map((query: StashQuery) => new StashQueryClass(query).toHtml(target, id, numQueries)).join("
"); 76 | } 77 | 78 | function formatEntryData(entry: StashEntry, target: Target, numQueries: number): string { 79 | return "
" + Object.entries(entry) 80 | .map(([key, value]: [string, any]) => value ? propertyStrings.get(key)?.(value, entry.queries, target, numQueries) : undefined) 81 | .filter(s => s) 82 | .join("") 83 | } 84 | 85 | /** 86 | * Similar to object.assign(), but also merges the children of the objects. 87 | */ 88 | function mergeData(target: StashEntry[], source: StashEntry[]): StashEntry[] { 89 | // Identify results by endpoint + id, merge identical ones 90 | let mapTarget: Map = new Map(target.map(e => [entryKey(e), e])) 91 | let mapSource: Map = new Map(source.map(e => [entryKey(e), e])) 92 | mapSource.forEach((sourceEntry, key) => { 93 | if (mapTarget.has(key)) { 94 | // Merge "queries"; Create maps: endpoint -> query 95 | let sourceQueries: Map = new Map(sourceEntry.queries.map(v => [v.endpoint, v])) 96 | let targetQueries: Map = new Map(mapTarget.get(key)!.queries.map(v => [v.endpoint, v])) 97 | 98 | sourceQueries.forEach((sourceQuery, key) => { 99 | if (targetQueries.has(key)) { 100 | let s = new StashQueryClass(sourceQuery) 101 | s.addTypes(targetQueries.get(key)!.types) 102 | sourceQuery = s 103 | } 104 | targetQueries.set(key, sourceQuery) 105 | }); 106 | 107 | // Sort and add new value 108 | sourceEntry.queries = Array.from(targetQueries.values()) 109 | .map(q => new StashQueryClass(q)) 110 | .sort((a, b) => a.compareTo(b)) 111 | } 112 | mapTarget.set(key, sourceEntry) 113 | }); 114 | return Array.from(mapTarget.values()); 115 | } 116 | 117 | function entryKey(entry: StashEntry): string { 118 | return `${entry.endpoint}-${entry.id}`; 119 | } 120 | 121 | function stashSymbol(): HTMLSpanElement { 122 | let symbol = document.createElement("span"); 123 | symbol.classList.add("stashCheckerSymbol"); 124 | symbol.setAttribute("data-type", "stash-symbol"); 125 | symbol.setAttribute("data-count", "1"); 126 | symbol.addEventListener("mouseover", mouseoverListener); 127 | symbol.addEventListener("mouseout", mouseoutListener); 128 | return symbol 129 | } 130 | 131 | /** 132 | * Prepends depending on the data the checkmark or cross to the selected element. 133 | * Also populates tooltip window. 134 | */ 135 | export function prefixSymbol( 136 | element: Element, 137 | target: Target, 138 | type: Type, 139 | endpoint: StashEndpoint, 140 | data: StashEntry[], 141 | display: DisplayOptions, 142 | ) { 143 | // All queries used here 144 | let endpoints = [endpoint.name]; 145 | let queryTypes = [type]; 146 | // Specific query for this result 147 | let baseUrl = endpoint.url.replace(/\/graphql\/?$/, ""); 148 | let query: StashQuery = {endpoint: endpoint.name, baseUrl, types: queryTypes}; 149 | // Add query, endpoint and display options to each new entry 150 | data.forEach((entry: StashEntry) => { 151 | entry.queries = [query] 152 | entry.endpoint = endpoint.name 153 | entry.display = display 154 | }); 155 | 156 | // Look for existing check symbol 157 | let symbol = getExistingSymbol(element); 158 | if (symbol) { 159 | // Merge new result with existing results 160 | endpoints = [...new Set(JSON.parse(symbol.getAttribute("data-endpoints")!)).add(endpoint.name)].sort(); 161 | queryTypes = [...new Set(JSON.parse(symbol.getAttribute("data-queries")!)).add(type)].sort(); 162 | data = mergeData(JSON.parse(symbol.getAttribute("data-data")!), data); 163 | symbol.setAttribute("data-count", (parseInt(symbol.getAttribute("data-count")!) + 1).toString()); 164 | } else { 165 | // insert new symbol before first text because css selectors cannot select text nodes directly 166 | // it works with cases were non text elements (images) are inside the selected element 167 | symbol = stashSymbol(); 168 | let text = firstTextChild(element); 169 | if (text) { 170 | // If node contains text, insert symbol before the text 171 | text.parentNode?.insertBefore(symbol, text); 172 | } else { 173 | return; // abort if no text in symbol 174 | } 175 | } 176 | // Store merged query results on symbol 177 | symbol.setAttribute("data-endpoints", JSON.stringify(endpoints)); 178 | symbol.setAttribute("data-target", target); 179 | symbol.setAttribute("data-queries", JSON.stringify(queryTypes)); 180 | symbol.setAttribute("data-data", JSON.stringify(data)); 181 | 182 | // Set symbol and tooltip content based on query results 183 | let count = data.length; 184 | let tooltip = ""; 185 | let targetReadable = readable(target); 186 | if (count === 0) { 187 | symbol.setAttribute("data-symbol", StashSymbol.Cross); 188 | if (booleanOptions.get(OptionKey.showCrossMark)) { 189 | symbol.innerHTML = `${stringOptions.get(OptionKey.crossMark)!} `; 190 | } 191 | symbol.style.color = "red"; 192 | tooltip = `${targetReadable} not in Stash
`; 193 | } else if (new Set(data.map(e => e.endpoint)).size < data.length) { 194 | symbol.setAttribute("data-symbol", StashSymbol.Warning); 195 | symbol.innerHTML = `${stringOptions.get(OptionKey.warningMark)!} `; 196 | symbol.style.color = "orange"; 197 | tooltip = `${targetReadable} has duplicate matches
`; 198 | } else { 199 | symbol.setAttribute("data-symbol", StashSymbol.Check); 200 | if (booleanOptions.get(OptionKey.showCheckMark)) { 201 | symbol.innerHTML = `${stringOptions.get(OptionKey.checkMark)!} `; 202 | } 203 | symbol.style.color = data[0].display.color; 204 | } 205 | 206 | // All used queries 207 | tooltip += `Endpoints: ${endpoints.join(", ")}`; 208 | tooltip += "
"; 209 | tooltip += `Queries: ${queryTypes.map(type => typeToString.get(type)).join(", ")}`; 210 | // List of results 211 | tooltip += data.map(entry => formatEntryData(entry, target, queryTypes.length)).join(""); 212 | 213 | // Store tooltip content on symbol 214 | symbol.setAttribute("data-info", tooltip) 215 | } 216 | -------------------------------------------------------------------------------- /src/tooltip/tooltipElement.ts: -------------------------------------------------------------------------------- 1 | 2 | import {computePosition, ComputePositionConfig, flip, offset} from "@floating-ui/dom"; 3 | 4 | export async function initTooltip() { 5 | let tooltipWindow = document.createElement("div"); 6 | tooltipWindow.style.display = "none"; 7 | tooltipWindow.classList.add("stashChecker", "tooltip"); 8 | tooltipWindow.id = "stashChecker-tooltipWindow"; 9 | tooltipWindow.addEventListener("mouseover", function () { 10 | let handle = parseInt(this.getAttribute("handle")!); 11 | window.clearTimeout(handle); 12 | }); 13 | tooltipWindow.addEventListener("mouseout", function () { 14 | let handle = window.setTimeout(function () { 15 | tooltipWindow.style.display = "none"; 16 | }, 500); 17 | this.setAttribute("handle", handle.toString()); 18 | }); 19 | document.body.append(tooltipWindow); 20 | } 21 | 22 | export function mouseoverListener(this: HTMLElement) { 23 | let tooltipWindow = document.getElementById("stashChecker-tooltipWindow")!; 24 | let handle = parseInt(tooltipWindow.getAttribute("handle")!); 25 | window.clearTimeout(handle); 26 | 27 | tooltipWindow.innerHTML = this.getAttribute("data-info")!; 28 | tooltipWindow.style.display = ""; 29 | 30 | // Floating-UI 31 | let config: ComputePositionConfig = { 32 | placement: 'top', 33 | strategy: 'absolute', 34 | middleware: [flip(), offset(10)] 35 | } 36 | computePosition(this, tooltipWindow, config).then(({x, y}) => { 37 | tooltipWindow.style.left = `${x}px` 38 | tooltipWindow.style.top = `${y}px` 39 | }); 40 | } 41 | 42 | export function mouseoutListener() { 43 | let tooltipWindow = document.getElementById("stashChecker-tooltipWindow")!; 44 | let handle = window.setTimeout(function () { 45 | tooltipWindow.style.display = "none"; 46 | }, 500); 47 | tooltipWindow.setAttribute("handle", handle.toString()); 48 | } 49 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {Target, Type} from "./dataTypes"; 2 | 3 | /** 4 | * recursive (dfs) first non empty text node child, undefined if none available 5 | */ 6 | export function firstTextChild(node?: Node | undefined | null): null | undefined | Node { 7 | if (!node) { 8 | return node; 9 | } 10 | if ( 11 | node.nodeType === Node.TEXT_NODE && 12 | node.textContent?.match(/^[\s<>]*$/) === null // exclude whitespace, <, > 13 | ) { 14 | return node; 15 | } else { 16 | return Array.from(node.childNodes) 17 | .filter(n => !["svg"].includes(n.nodeName.toLowerCase())) // element tag exceptions 18 | .filter(n => asElement(n)?.getAttribute("data-type") !== "stash-symbol") // exclude checkmark 19 | .filter(n => isElement(n) ? !isHidden(n as Element) : true) // exclude hidden elements 20 | .map(firstTextChild) 21 | .find(n => n); // first truthy 22 | } 23 | } 24 | 25 | function isElement(childNode: ChildNode): boolean { 26 | return childNode.nodeType === Node.ELEMENT_NODE 27 | } 28 | 29 | function asElement(childNode: ChildNode): Element | null { 30 | if (isElement(childNode)) return childNode as Element 31 | else return null 32 | } 33 | 34 | function isHidden(element: Element): boolean { 35 | // element.computedStyleMap()?.getAll("display")?.includes("none") // not supported yet by firefox (https://bugzilla.mozilla.org/show_bug.cgi?id=1857849) 36 | return window.getComputedStyle(element).display === "none" 37 | } 38 | 39 | export function firstText(node?: Node | undefined | null): string | undefined { 40 | return firstTextChild(node)?.textContent?.trim() 41 | } 42 | 43 | export function allText(node?: Node | undefined | null): string[] { 44 | let words: any[] = node ? Array.from(node.childNodes) 45 | .flatMap(n => n.nodeType == Node.TEXT_NODE ? [n.textContent] : allText(n)) 46 | .filter((s: string | null) => s) : [] 47 | return words; 48 | } 49 | 50 | export function entryLink(stashUrl: string, target: Target, id: string): string { 51 | let path 52 | if (target == "gallery") { 53 | path = "galleries"; 54 | } else { 55 | path = target + "s"; 56 | } 57 | let url = `${stashUrl}/${path}/${id}`; 58 | return `${url}` 59 | } 60 | 61 | export function secondsToReadable(seconds: number): string { 62 | let h = Math.floor(seconds / 3600) 63 | let m = Math.floor(seconds / 60) % 60 64 | let s = Math.floor(seconds) % 60 65 | return [h, m, s] 66 | .map(v => v.toString().padStart(2, "0")) 67 | .filter((v, i) => v !== "00" || i > 0) 68 | .join(":") 69 | } 70 | 71 | export function bytesToReadable(bytes: number): string { 72 | let labels = ["KB", "MB", "GB", "TB", "PB"] 73 | let label 74 | for (label of labels) { 75 | bytes /= 1000 76 | if (bytes < 1000) { 77 | break; 78 | } 79 | } 80 | return bytes.toFixed(2) + label; 81 | } 82 | 83 | export function hasKanji(text: string): boolean { 84 | return /[\u4e00-\u9faf\u3400-\u4dbf]/.test(text) 85 | } 86 | 87 | export function hasKana(text: string): boolean { 88 | return /[\u3041-\u3096\u30a0-\u30ff\uff5f-\uff9f]/.test(text) 89 | } 90 | 91 | export function capitalized(word: string): string { 92 | return word[0].toUpperCase() + word.slice(1).toLowerCase() 93 | } 94 | 95 | export function titleCase(text: string): string { 96 | return text.split(" ").map(n => capitalized(n)).join(" "); 97 | } 98 | 99 | export function nakedDomain(url: string): string { 100 | const regex = /^(https?:\/\/)?(www\.)?/i; 101 | return url.replace(regex, ''); 102 | } 103 | 104 | export function interleave(array: T[], between: T): T[] { 105 | return array.flatMap(element => [element, between.cloneNode(true) as T]).slice(0, -1) 106 | } 107 | 108 | export function moveIndex(list: T[], oldIndex: number, newIndex: number) { 109 | let moved = list.splice(oldIndex, 1)[0]; 110 | list.splice(newIndex, 0, moved); 111 | return list; 112 | } 113 | 114 | export let friendlyHttpStatus: Map = new Map([ 115 | [200, "OK"], 116 | [201, "Created"], 117 | [202, "Accepted"], 118 | [203, "Non-Authoritative Information"], 119 | [204, "No Content"], 120 | [205, "Reset Content"], 121 | [206, "Partial Content"], 122 | [300, "Multiple Choices"], 123 | [301, "Moved Permanently"], 124 | [302, "Found"], 125 | [303, "See Other"], 126 | [304, "Not Modified"], 127 | [305, "Use Proxy"], 128 | [306, "Unused"], 129 | [307, "Temporary Redirect"], 130 | [400, "Bad Request"], 131 | [401, "Unauthorized"], 132 | [402, "Payment Required"], 133 | [403, "Forbidden"], 134 | [404, "Not Found"], 135 | [405, "Method Not Allowed"], 136 | [406, "Not Acceptable"], 137 | [407, "Proxy Authentication Required"], 138 | [408, "Request Timeout"], 139 | [409, "Conflict"], 140 | [410, "Gone"], 141 | [411, "Length Required"], 142 | [412, "Precondition Required"], 143 | [413, "Request Entry Too Large"], 144 | [414, "Request-URI Too Long"], 145 | [415, "Unsupported Media Type"], 146 | [416, "Requested Range Not Satisfiable"], 147 | [417, "Expectation Failed"], 148 | [418, "I'm a teapot"], 149 | [429, "Too Many Requests"], 150 | [500, "Internal Server Error"], 151 | [501, "Not Implemented"], 152 | [502, "Bad Gateway"], 153 | [503, "Service Unavailable"], 154 | [504, "Gateway Timeout"], 155 | [505, "HTTP Version Not Supported"], 156 | ]); 157 | 158 | export const typeToString = new Map([ 159 | [Type.Url, "URL"], 160 | [Type.Code, "Code"], 161 | [Type.StashId, "StashId"], 162 | [Type.Name, "Name"], 163 | [Type.Title, "Title"], 164 | ]); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "esModuleInterop": true, 5 | "noImplicitAny": true, 6 | "strict": true, 7 | "moduleResolution": "Node", 8 | "module": "ESNext", 9 | "target": "ES2021", 10 | "allowJs": true, 11 | "allowSyntheticDefaultImports": true, 12 | "lib": ["ES2021", "dom"], 13 | "sourceMap": true, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {fileURLToPath} from "url"; 3 | import {UserscriptPlugin} from "webpack-userscript"; 4 | import TerserPlugin from "terser-webpack-plugin"; 5 | import metadata from "./metadata.js"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const dev = process.env.NODE_ENV === "development"; 11 | if (dev) { 12 | metadata.name += " Dev" 13 | metadata.updateURL = undefined 14 | metadata.downloadURL = undefined 15 | } 16 | 17 | export default { 18 | mode: dev ? "development" : "production", 19 | entry: path.resolve(__dirname, "src", "index.ts"), 20 | resolve: { 21 | extensions: [".js", ".tsx", ".ts"], 22 | }, 23 | output: { 24 | path: path.resolve(__dirname, "dist"), 25 | filename: dev ? "index.dev.js" : "index.prod.js", 26 | }, 27 | devtool: dev ? "eval" : false, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.tsx?$/, 32 | use: { 33 | loader: "ts-loader", 34 | }, 35 | exclude: /node-modules/, 36 | }, 37 | { 38 | test: /\.css$/, 39 | oneOf: [ 40 | { 41 | test: /_important\.css$/, 42 | use: ["style-loader", "css-loader", "cssimportant-loader"], 43 | exclude: /node-modules/, 44 | }, 45 | { 46 | use: ["style-loader", "css-loader"], 47 | exclude: /node-modules/, 48 | }, 49 | ], 50 | }, 51 | { 52 | test: /\.s[ac]ss$/i, 53 | oneOf: [ 54 | { 55 | test: /_important\.s[ac]ss$/i, 56 | use: ["style-loader", "css-loader", "cssimportant-loader", "sass-loader"], 57 | exclude: /node-modules/, 58 | }, 59 | { 60 | use: ["style-loader", "css-loader", "sass-loader"], 61 | exclude: /node-modules/, 62 | }, 63 | ], 64 | } 65 | ], 66 | }, 67 | optimization: dev ? undefined : { 68 | minimize: true, 69 | minimizer: [new TerserPlugin({ 70 | terserOptions: { 71 | mangle: false, 72 | compress: { 73 | defaults: false, 74 | ecma: "2020", 75 | drop_console: ["debug"], 76 | }, 77 | format: { 78 | comments: false, 79 | indent_level: 2, 80 | beautify: true, 81 | }, 82 | } 83 | })], 84 | }, 85 | experiments: { 86 | topLevelAwait: true, 87 | }, 88 | devServer: { 89 | port: 8080, 90 | hot: false, 91 | client: false, 92 | devMiddleware: { 93 | writeToDisk: true, 94 | }, 95 | static: { 96 | directory: path.join(__dirname, "dist"), 97 | }, 98 | }, 99 | plugins: [ 100 | new UserscriptPlugin({ 101 | headers: metadata, 102 | proxyScript: dev ? { 103 | baseUrl: "http://localhost:8080", 104 | filename: "[basename].proxy.user.js", 105 | } : undefined, 106 | }), 107 | ], 108 | }; 109 | --------------------------------------------------------------------------------