├── .gitattributes ├── .github ├── images │ └── F95Checker.png ├── workflow_data │ └── release.py └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── browser ├── .prettierrc ├── chrome.zip ├── chrome │ ├── extension.js │ ├── fonts │ │ └── mdi-webfont.ttf │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 32.png │ │ └── 64.png │ └── manifest.json ├── firefox.zip ├── firefox │ ├── extension.js │ ├── fonts │ │ └── mdi-webfont.ttf │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 32.png │ │ └── 64.png │ └── manifest.json └── integrated.js ├── common ├── meta.py ├── parser.py └── structs.py ├── external ├── async_thread.py ├── cpuinfo.py ├── error.py ├── filepicker.py ├── imagehelper.py ├── imgui_glfw.py ├── ratingwidget.py ├── singleton.py ├── sync_thread.py ├── weakerset.py └── ziparch.py ├── indexer-main.py ├── indexer.env-example ├── indexer ├── cache.py ├── f95zone.py ├── scraper.py ├── threads.py └── watcher.py ├── main-debug.py ├── main.py ├── modules ├── api.py ├── callbacks.py ├── colors.py ├── db.py ├── globals.py ├── gui.py ├── icons.py ├── msgbox.py ├── notification_proc.py ├── patches.py ├── rpc_thread.py ├── rpdl.py ├── utils.py └── webview.py ├── requirements-dev.txt ├── requirements-indexer.txt ├── requirements.txt ├── resources ├── fonts │ ├── Karla-Bold.27.09.2023.ttf │ ├── Karla-Regular.27.09.2023.ttf │ ├── MesloLGS-Regular.1.2.ttf │ ├── NotoSans-Regular.27.02.2014.ttf │ ├── custom-mixin.ttf │ └── materialdesignicons-webfont.7.4.47.ttf └── icons │ ├── error.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── logo.astc.ktx.zst │ ├── logo.bc7.ktx.zst │ ├── logo.png │ ├── paused.png │ ├── refreshing.png │ ├── refreshing1.png │ ├── refreshing10.png │ ├── refreshing11.png │ ├── refreshing12.png │ ├── refreshing13.png │ ├── refreshing14.png │ ├── refreshing15.png │ ├── refreshing16.png │ ├── refreshing2.png │ ├── refreshing3.png │ ├── refreshing4.png │ ├── refreshing5.png │ ├── refreshing6.png │ ├── refreshing7.png │ ├── refreshing8.png │ └── refreshing9.png ├── setup.py └── tags-diff.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.bat eol=crlf 3 | *.ps1 eol=crlf 4 | *.cmd eol=crlf 5 | -------------------------------------------------------------------------------- /.github/images/F95Checker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/.github/images/F95Checker.png -------------------------------------------------------------------------------- /.github/workflow_data/release.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import os 4 | 5 | 6 | if __name__ == "__main__": 7 | with open("CHANGELOG.md") as f: 8 | changelog = f.read() 9 | with open(os.environ["GITHUB_EVENT_PATH"]) as f: 10 | event = json.load(f) 11 | print(f"event = {json.dumps(event, indent=4)}") 12 | release = requests.get( 13 | event["release"]["url"], 14 | headers={ 15 | "Accept": "application/vnd.github.v3+json", 16 | "Authorization": f"token {os.environ['GITHUB_TOKEN']}" 17 | } 18 | ).json() 19 | print(f"release = {json.dumps(release, indent=4)}") 20 | body = "## ⬇️ Download\n" 21 | for asset_type, asset_icon in [("Windows", "🪟"), ("Linux", "🐧"), ("MacOS", "🍎"), ("Source", "🐍")]: 22 | print(f"Adding {asset_type}") 23 | for asset in release["assets"]: 24 | if asset_type.lower() in asset["name"].lower(): 25 | asset_url = asset["browser_download_url"] 26 | body += f">### [{asset_type} {asset_icon}]({asset_url}) ([VirusTotal](https://www.virustotal.com/gui/file/))\n\n" 27 | body += ( 28 | "## ❤️ Support\n" + 29 | "F95Checker is **Free and Open Source Software**, provided to you **free of cost**. However it is actively **developed by " + 30 | "one single person only, WillyJL**. Please consider [**donating**](https://linktr.ee/WillyJL) or **sharing this software**!\n\n" + 31 | "## 🚀 Changelog\n" + 32 | changelog 33 | ) 34 | print(f"Full body:\n\n{body}") 35 | req = requests.patch( 36 | release["url"], 37 | headers={ 38 | "Accept": "application/vnd.github.v3+json", 39 | "Authorization": f"token {os.environ['GITHUB_TOKEN']}" 40 | }, 41 | json={ 42 | "body": body 43 | } 44 | ) 45 | if not req.ok: 46 | print(f"{req.status_code = }\n{req.content = }") 47 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | release: 7 | types: 8 | - "published" 9 | 10 | jobs: 11 | 12 | build: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | config: 17 | - { os: "windows-2022", python: "3.12.4", cx-freeze: "7.0.0", cx-logging: "v3.2.0", astcenc: "5.1.0", compressonator: "4.5.52" } 18 | - { os: "ubuntu-22.04", python: "3.12.4", cx-freeze: "7.0.0", cx-logging: "" , astcenc: "5.1.0", compressonator: "4.5.52" } 19 | - { os: "macos-13", python: "3.12.4", cx-freeze: "7.0.0", cx-logging: "" , astcenc: "5.1.0", compressonator: "" } 20 | name: "${{ matrix.config.os }}" 21 | runs-on: "${{ matrix.config.os }}" 22 | if: "github.event_name != 'push' || contains(github.event.head_commit.message, '+ BUILD')" 23 | steps: 24 | 25 | # === SETUP === 26 | 27 | - name: "Set git to use lf" 28 | run: | 29 | git config --global core.autocrlf false 30 | git config --global core.eol lf 31 | 32 | - name: "Setup Python" 33 | uses: "actions/setup-python@v5" 34 | with: 35 | python-version: "${{ matrix.config.python }}" 36 | 37 | - name: "Setup cx_Freeze" 38 | env: 39 | CIBUILDWHEEL: "1" 40 | shell: bash 41 | run: | 42 | if [ "${{ matrix.config.cx-logging }}" != "" ] ; then 43 | python -m pip install -U 'git+https://github.com/anthony-tuininga/cx_Logging.git@${{ matrix.config.cx-logging }}' 44 | fi 45 | python -m pip install -U 'git+https://github.com/marcelotduarte/cx_Freeze.git@${{ matrix.config.cx-freeze }}' 46 | 47 | # === BUILD === 48 | 49 | - name: "Clone repo" 50 | uses: "actions/checkout@v4" 51 | with: 52 | path: "." 53 | 54 | - name: "Install requirements" 55 | run: | 56 | python -m pip install -U -r ./requirements.txt 57 | 58 | - name: "Mark as release" 59 | if: "github.event_name == 'release'" 60 | run: | 61 | sed "s/release = False/release = True/g" ./common/meta.py > ./common/meta.py.new 62 | rm ./common/meta.py 63 | mv ./common/meta.py.new ./common/meta.py 64 | 65 | - name: "Mark build number" 66 | if: "github.event_name != 'release'" 67 | run: | 68 | sed "s/build_number = 0/build_number = ${{ github.run_number }}/g" ./common/meta.py > ./common/meta.py.new 69 | rm ./common/meta.py 70 | mv ./common/meta.py.new ./common/meta.py 71 | 72 | - name: "Install dependencies (Linux)" 73 | if: "runner.os == 'Linux'" 74 | run: | 75 | sudo apt install -y libxcb-cursor-dev 76 | 77 | - name: "Build (Windows, Linux)" 78 | if: "runner.os != 'macOS'" 79 | run: | 80 | python ./setup.py build 81 | mv ./build/exe.*/ ./dist/ 82 | 83 | - name: "Build (macOS)" 84 | if: "runner.os == 'macOS'" 85 | run: | 86 | python ./setup.py bdist_mac 87 | mkdir ./dist/ 88 | mv ./build/*.app/ ./dist/ 89 | 90 | - name: "Resolve symlinks (Linux, macOS)" 91 | if: "runner.os != 'Windows'" 92 | run: | 93 | cd ./dist/ 94 | find ./ -type l -exec echo Resolving {} \; -exec sed -i '' {} \; 95 | 96 | # - name: "Import codesign certificate (macOS)" 97 | # if: "runner.os == 'macOS'" 98 | # uses: "apple-actions/import-codesign-certs@v1" 99 | # with: 100 | # p12-file-base64: "${{ secrets.CODESIGN_P12_BASE64 }}" 101 | # p12-password: "${{ secrets.CODESIGN_P12_PASSWORD }}" 102 | 103 | # - name: "Codesign (macOS)" 104 | # if: "runner.os == 'macOS'" 105 | # run: | 106 | # cd ./dist/ 107 | # find ./ -type f -empty -delete 108 | # codesign -s "${{ secrets.CODESIGN_P12_NAME }}" --deep ./*.app 109 | 110 | - name: "Bundle astcenc+compressonator (Windows)" 111 | if: "runner.os == 'Windows'" 112 | shell: bash 113 | run: | 114 | curl -L -o astcenc-x64.zip https://github.com/ARM-software/astc-encoder/releases/download/${{ matrix.config.astcenc }}/astcenc-${{ matrix.config.astcenc }}-windows-x64.zip 115 | curl -L -o astcenc-arm64.zip https://github.com/ARM-software/astc-encoder/releases/download/${{ matrix.config.astcenc }}/astcenc-${{ matrix.config.astcenc }}-windows-arm64.zip 116 | 7z x astcenc-x64.zip 117 | 7z x astcenc-arm64.zip 118 | mkdir ./dist/lib/astcenc/ 119 | mv ./bin/astcenc-avx2.exe ./dist/lib/astcenc 120 | mv ./bin/astcenc-sse2.exe ./dist/lib/astcenc 121 | mv ./bin/astcenc-neon.exe ./dist/lib/astcenc 122 | curl -L -o compressonatorcli.zip https://github.com/GPUOpen-Tools/compressonator/releases/download/V${{ matrix.config.compressonator }}/compressonatorcli-${{ matrix.config.compressonator }}-win64.zip 123 | 7z x compressonatorcli.zip 124 | rm -rf compressonatorcli-*/{documents,images} 125 | mv ./compressonatorcli-*/ ./dist/lib/compressonator/ 126 | 127 | - name: "Bundle astcenc+compressonator (Linux)" 128 | if: "runner.os == 'Linux'" 129 | run: | 130 | curl -L -o astcenc-x64.zip https://github.com/ARM-software/astc-encoder/releases/download/${{ matrix.config.astcenc }}/astcenc-${{ matrix.config.astcenc }}-linux-x64.zip 131 | 7z x astcenc-x64.zip 132 | mkdir ./dist/lib/astcenc/ 133 | mv ./bin/astcenc-avx2 ./dist/lib/astcenc/ 134 | mv ./bin/astcenc-sse2 ./dist/lib/astcenc/ 135 | curl -L -o compressonatorcli.tar.gz https://github.com/GPUOpen-Tools/compressonator/releases/download/V${{ matrix.config.compressonator }}/compressonatorcli-${{ matrix.config.compressonator }}-Linux.tar.gz 136 | tar xzf compressonatorcli.tar.gz 137 | rm -rf compressonatorcli-*/{documents,images} 138 | mv ./compressonatorcli-*/ ./dist/lib/compressonator/ 139 | 140 | - name: "Bundle astcenc (macOS)" 141 | if: "runner.os == 'macOS'" 142 | run: | 143 | curl -L -o astcenc-universal.zip https://github.com/ARM-software/astc-encoder/releases/download/${{ matrix.config.astcenc }}/astcenc-${{ matrix.config.astcenc }}-macos-universal.zip 144 | 7z x astcenc-universal.zip 145 | mkdir $(echo ./dist/*.app)/Contents/Resources/lib/astcenc/ 146 | mv ./bin/astcenc ./dist/*.app/Contents/Resources/lib/astcenc/ 147 | 148 | # === ARTIFACT === 149 | 150 | - name: "Zip artifact" 151 | run: | 152 | cd ./dist/ 153 | 7z a ../${{ github.event.repository.name }}-${{ runner.os }}.zip . 154 | 155 | - name: "Upload commit artifact" 156 | if: "github.event_name != 'release'" 157 | uses: "actions/upload-artifact@v4" 158 | with: 159 | name: "${{ github.event.repository.name }}-${{ runner.os }}-Artifact" 160 | path: "./${{ github.event.repository.name }}-${{ runner.os }}.zip" 161 | compression-level: 0 162 | 163 | - name: "Upload release artifact" 164 | if: "github.event_name == 'release'" 165 | uses: "softprops/action-gh-release@v1" 166 | env: 167 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 168 | with: 169 | files: "./${{ github.event.repository.name }}-${{ runner.os }}.zip" 170 | 171 | source: 172 | name: "source" 173 | runs-on: "ubuntu-latest" 174 | if: "github.event_name != 'push' || contains(github.event.head_commit.message, '+ BUILD')" 175 | steps: 176 | 177 | # === SETUP === 178 | 179 | - name: "Set git to use lf" 180 | run: | 181 | git config --global core.autocrlf false 182 | git config --global core.eol lf 183 | 184 | # === PACKAGE === 185 | 186 | - name: "Clone repo" 187 | uses: "actions/checkout@v4" 188 | with: 189 | path: "." 190 | 191 | - name: "Mark as release" 192 | if: "github.event_name == 'release'" 193 | run: | 194 | sed "s/release = False/release = True/g" ./common/meta.py > ./common/meta.py.new 195 | rm ./common/meta.py 196 | mv ./common/meta.py.new ./common/meta.py 197 | 198 | - name: "Mark build number" 199 | if: "github.event_name != 'release'" 200 | run: | 201 | sed "s/build_number = 0/build_number = ${{ github.run_number }}/g" ./common/meta.py > ./common/meta.py.new 202 | rm ./common/meta.py 203 | mv ./common/meta.py.new ./common/meta.py 204 | 205 | - name: "Package" 206 | run: | 207 | python ./setup.py || true 208 | mkdir ./dist/ 209 | cp -r ./browser/ ./dist/ 210 | cp -r ./common/ ./dist/ 211 | cp -r ./external/ ./dist/ 212 | cp -r ./modules/ ./dist/ 213 | cp -r ./resources/ ./dist/ 214 | cp ./LICENSE ./dist/ 215 | cp ./main.py ./dist/ 216 | cp ./main-debug.py ./dist/ 217 | cp ./requirements.txt ./dist/ 218 | 219 | # === ARTIFACT === 220 | 221 | - name: "Zip artifact" 222 | run: | 223 | cd ./dist/ 224 | 7z a ../${{ github.event.repository.name }}-Source.zip . 225 | 226 | - name: "Upload commit artifact" 227 | if: "github.event_name != 'release'" 228 | uses: "actions/upload-artifact@v4" 229 | with: 230 | name: "${{ github.event.repository.name }}-Source-Artifact" 231 | path: "./${{ github.event.repository.name }}-Source.zip" 232 | compression-level: 0 233 | 234 | - name: "Upload release artifact" 235 | if: "github.event_name == 'release'" 236 | uses: "softprops/action-gh-release@v1" 237 | env: 238 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 239 | with: 240 | files: "./${{ github.event.repository.name }}-Source.zip" 241 | 242 | release: 243 | name: "release" 244 | runs-on: "ubuntu-latest" 245 | if: "github.event_name == 'release'" 246 | needs: 247 | - build 248 | - source 249 | steps: 250 | 251 | - name: "Set git to use lf" 252 | run: | 253 | git config --global core.autocrlf false 254 | git config --global core.eol lf 255 | 256 | - name: "Clone repo" 257 | uses: "actions/checkout@v4" 258 | with: 259 | path: "." 260 | 261 | - name: "Update release" 262 | env: 263 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 264 | run: | 265 | python ./.github/workflow_data/release.py 266 | 267 | - name: "Delete skipped workflow runs" 268 | if: "github.event_name == 'release'" 269 | uses: "WillyJL/delete-skipped-workflow-runs@main" 270 | with: 271 | retain_days: 0 272 | keep_minimum_runs: 0 273 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .venv/ 4 | venv/ 5 | __pycache__/ 6 | build/ 7 | dist/ 8 | *.env 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Added: 2 | - Reviews Tab in More Info Popup (#224 by @WillyJL) 3 | - Labels moved next to executables 4 | - Image Texture Compression (`(ASTC or BC7) + ZSTD`) option (#212 by @WillyJL): 5 | - Compresses images for instantaneous load times (after first compression which is slower) 6 | - Less VRAM usage, and potentially less disk usage, depending on configuration and GPU support 7 | - Check the hover info in settings section for details on trade-offs 8 | - Unload Images Off-screen option (#212 by @WillyJL): 9 | - Saves a lot of VRAM usage by unloading images not currently shown 10 | - Works best together with Tex Compress, so image load times are less noticeable 11 | - Preload Nearby Images option (by @WillyJL): 12 | - Starts loading images that aren't visible yet but are less than a window width/height scroll away 13 | - Works best together with Tex Compress, so image load times are completely unnoticeable 14 | - Play GIFs and Play GIFs Unfocused options (#212 by @WillyJL): 15 | - Saves a lot of VRAM if completely disabled, no GIFs play and only first frame is loaded 16 | - Saves CPU/GPU usage by redrawing less if disabled when unfocused, but still uses same VRAM 17 | - Tabs can now be reordered by dragging (by @WillyJL) 18 | 19 | ### Updated: 20 | - New notification system with buttons and better platform support, option to include banner image in update notifs (#220 by @WillyJL) 21 | - Updates popup is now cumulative, new updates get grouped with any previous popups and moved to top (#220 by @WillyJL) 22 | - Executable paths in More Info popup wrap after `/` and `\` characters for easier readability (by @WillyJL) 23 | 24 | ### Fixed: 25 | - Fix switching view modes with "Table header outside list" disabled (by @WillyJL) 26 | - Fix flashbang while interface is loading (#221 by @sodamouse) 27 | - Fix GUI redraws not pausing when unfocused, hovered and not moving mouse (by @WillyJL) 28 | - Fix missing `libbz2.so` on linux binary bundles (#222 by @WillyJL) 29 | - Apply images more efficiently, eliminate stutters while scrolling, start showing GIFs before all frames are loaded (by @WillyJL) 30 | - Improve images error handling and display (#212 by @WillyJL) 31 | - Tags now sort alphabetically as expected (by @WillyJL) 32 | - UTF-8 encoding is now forced (#230 by @WillyJL) 33 | - Fix adding executables focusing the wrong folder if the game type is in only one of the folders (by @WillyJL) 34 | - Fix marking as executable on Linux/MacOS with RenPy games including a dot in their name (by @WillyJL) 35 | - Fix readability on some framed text with dark text (by @WillyJL) 36 | - RPDL token is regenerated once it expires (by @WillyJL) 37 | 38 | ### Removed: 39 | - Excluded `libEGL.so` on linux binary bundles, fixes "Cannot find EGLConfig, returning null config" (by @WillyJL) 40 | 41 | ### Known Issues: 42 | - MacOS webview in frozen binaries remains blank, run from source instead 43 | -------------------------------------------------------------------------------- /browser/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 100, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /browser/chrome.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome.zip -------------------------------------------------------------------------------- /browser/chrome/extension.js: -------------------------------------------------------------------------------- 1 | const rpcPort = 57095; 2 | const rpcURL = `http://127.0.0.1:${rpcPort}`; 3 | let games = []; 4 | let settings = {}; 5 | 6 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 7 | 8 | const rpcCall = async (method, path, body, tabId) => { 9 | if (typeof method !== 'string' || typeof path !== 'string' || (typeof body !== 'string' && body !== null)) { 10 | return {}; 11 | } 12 | try { 13 | const res = await fetch(`${rpcURL}${path}`, { 14 | method: method, 15 | body: body, 16 | }); 17 | if (!res.ok) { 18 | throw res.status; 19 | } 20 | return res; 21 | } catch { 22 | if (tabId) { 23 | chrome.scripting.executeScript({ 24 | target: { tabId: tabId }, 25 | func: () => { 26 | alert( 27 | 'Could not connect to F95Checker!\nIs it open and updated? Is RPC enabled?' 28 | ); 29 | }, 30 | }); 31 | } 32 | } 33 | }; 34 | 35 | const getData = async () => { 36 | let res; 37 | res = await rpcCall('GET', '/games', null); 38 | games = res ? await res.json() : []; 39 | res = await rpcCall('GET', '/settings', null); 40 | settings = res ? await res.json() : { 41 | "icon_glow": true, 42 | "highlight_tags": false, 43 | "tags_highlights": {}, 44 | }; 45 | }; 46 | 47 | const addGame = async (url, tabId) => { 48 | await rpcCall('POST', '/games/add', JSON.stringify([url]), tabId); 49 | await sleep(0.5 * 1000); 50 | await updateIcons(tabId); 51 | }; 52 | 53 | // Add icons for games, reminders, etc. 54 | const updateIcons = async (tabId) => { 55 | await getData(); 56 | chrome.scripting.executeScript({ 57 | target: { tabId: tabId }, 58 | func: (games, settings, rpcURL) => { 59 | const injectCustomWebfont = () => { 60 | const styleTag = document.createElement('style'); 61 | const cssContent = String.raw` 62 | @font-face{ 63 | font-family: "MDI Custom"; 64 | src: url('${chrome.runtime.getURL("fonts/mdi-webfont.ttf")}') format('truetype'); 65 | font-weight: normal; 66 | font-style: normal; 67 | } 68 | .mdi:before { 69 | display: inline-block; 70 | font: normal normal normal 24px/1 "MDI Custom"; 71 | font-size: inherit; 72 | text-rendering: auto; 73 | line-height: inherit; 74 | -webkit-font-smoothing: antialiased; 75 | -moz-osx-font-smoothing: grayscale; 76 | } 77 | .mdi::before { 78 | content: var(--mdi-i); 79 | } 80 | `; 81 | styleTag.appendChild(document.createTextNode(cssContent)); 82 | document.head.appendChild(styleTag); 83 | }; 84 | const extractThreadId = (url) => { 85 | const match = /threads\/(?:(?:[^\.\/]*)\.)?(\d+)/.exec(url); 86 | return match ? parseInt(match[1]) : null; 87 | }; 88 | const createContainer = () => { 89 | const c = document.createElement('div'); 90 | c.classList.add('f95checker-library-icons'); 91 | c.style.display = 'inline-block'; 92 | return c; 93 | }; 94 | const createIcon = (gameId) => { 95 | const icon = document.createElement('i'); 96 | const game = games.find(g => g.id === gameId); 97 | icon.classList.add('mdi'); 98 | icon.style.setProperty('--mdi-i', `'${game.icon}'`); 99 | let tooltiptext = 'This game is present in your F95Checker library!'; 100 | if (game.notes) { 101 | tooltiptext += `\n\nNOTES: ${game.notes}`; 102 | } 103 | icon.setAttribute('title', tooltiptext); 104 | icon.addEventListener('click', () => 105 | alert(tooltiptext) 106 | ); 107 | icon.style.color = game.color; 108 | return [icon, game.color]; 109 | }; 110 | const createNbsp = () => { 111 | const span = document.createElement('span'); 112 | span.style.display = 'inline-block'; 113 | span.innerHTML = ' '; 114 | return span; 115 | }; 116 | const removeOldIcons = () => { 117 | document.querySelectorAll('.f95checker-library-icons').forEach((e) => e.remove()); 118 | }; 119 | const isValidHrefElem = (elem, elemId, pageId) => { 120 | // Ignore Reply and Quote buttons 121 | if (/reply\?.*$/.test(elem.href)) return false; 122 | 123 | // Ignore post navigation 124 | const parent = elem.parentNode 125 | if (/page-.*$/.test(elem.href)) return false; 126 | if (parent && parent.classList.contains('pageNav')) return false; 127 | if (parent && parent.classList.contains('pageNav-page')) return false; 128 | 129 | // Ignore post numbers 130 | const ul = elem.closest('ul') 131 | if (ul && ul.classList.contains('message-attribution-opposite')) return false; 132 | // Ignore links in the OP pointing to the posts in the same thread 133 | if (elem.closest('.message-threadStarterPost') && elemId === pageId) return false; 134 | 135 | // Ignore non-links 136 | if (elem.classList.contains('button')) return false; 137 | if (elem.classList.contains('tabs-tab')) return false; 138 | if (elem.classList.contains('u-concealed')) return false; 139 | 140 | return true; 141 | } 142 | const addHrefIcons = () => { 143 | const pageId = extractThreadId(document.location) 144 | for (const elem of document.querySelectorAll('a[href*="/threads/"]')) { 145 | const elemId = extractThreadId(elem.href); 146 | 147 | if (!elemId || !games.map(g => g.id).includes(elemId)) { 148 | continue; 149 | } 150 | 151 | const isImage = 152 | elem.classList.contains('resource-tile_link') || 153 | elem.parentNode.parentNode.classList.contains('es-slides'); 154 | 155 | if (!isImage && !isValidHrefElem(elem, elemId, pageId)) { 156 | continue; 157 | } 158 | 159 | const container = createContainer(); 160 | const [icon, color] = createIcon(elemId); 161 | container.prepend(icon); 162 | 163 | if (isImage) { 164 | container.style.position = 'absolute'; 165 | container.style.zIndex = '50'; 166 | container.style.left = '5px'; 167 | container.style.top = '5px'; 168 | container.style.width = '28px'; 169 | container.style.textAlign = 'center'; 170 | container.style.background = '#262626'; 171 | container.style.borderRadius = '4px'; 172 | container.style.fontSize = '1.5em'; 173 | if (settings.icon_glow) { 174 | container.style.boxShadow = `0px 0px 30px 30px ${color.slice(0, 7)}bb`; 175 | } 176 | } 177 | 178 | if (!isImage && elem.children.length > 0) { 179 | // Search page 180 | container.style.fontSize = '1.2em'; 181 | container.style.verticalAlign = '-2px'; 182 | const whitespaces = elem.querySelectorAll('span.label-append'); 183 | if(whitespaces.length > 0) { 184 | const lastWhitespace = whitespaces[whitespaces.length - 1]; 185 | lastWhitespace.insertAdjacentElement('afterend', createNbsp()); 186 | lastWhitespace.insertAdjacentElement('afterend', container); 187 | } else if (elem.classList.contains('link--internal')) { 188 | if (elem.querySelector('img[data-src]')) { 189 | continue; 190 | } 191 | elem.insertAdjacentElement('beforebegin', container); 192 | elem.insertAdjacentElement('beforebegin', createNbsp()); 193 | } else { 194 | continue; 195 | } 196 | } else if (elem.classList.contains('resource-tile_link')) { 197 | // To accomodate all tile layouts on latest updates page 198 | const thumb = elem.querySelector('div.resource-tile_thumb'); 199 | thumb.insertAdjacentElement('beforebegin', container); 200 | } else { 201 | // Everywhere else 202 | container.style.fontSize = '1.2em'; 203 | container.style.verticalAlign = '-2px'; 204 | elem.insertAdjacentElement('beforebegin', container); 205 | elem.insertAdjacentElement('beforebegin', createNbsp()); 206 | } 207 | } 208 | }; 209 | const addPageIcon = () => { 210 | const id = extractThreadId(document.location); 211 | const container = createContainer(); 212 | container.style.fontSize = '1.3em'; 213 | container.style.verticalAlign = '-3px'; 214 | const title = document.getElementsByClassName('p-title-value')[0]; 215 | if (title) { 216 | if (games.map(g => g.id).includes(id)) { 217 | const [icon, _] = createIcon(id); 218 | container.prepend(icon); 219 | title.insertBefore( 220 | container, 221 | title.childNodes[title.childNodes.length - 1] 222 | ); 223 | title.insertBefore( 224 | createNbsp(), 225 | title.childNodes[title.childNodes.length - 1] 226 | ); 227 | }; 228 | } 229 | }; 230 | const installHighlighterMutationObservers = () => { 231 | const tiles = document.querySelectorAll('div.resource-tile_body'); 232 | tiles.forEach((tile) => { 233 | const observer = new MutationObserver(highlightTags); 234 | observer.observe(tile, { attributes: true, subtree: true }); 235 | }); 236 | } 237 | const highlightTags = () => { 238 | const highlightColors = { 239 | 1: { text: 'white', background: '#006600', border: '1px solid #ffffff55' }, // Positive 240 | 2: { text: 'white', background: '#990000', border: '1px solid #ffffff55' }, // Negative 241 | 3: { text: 'white', background: '#000000', border: '1px solid #ffffff55' }, // Critical 242 | }; 243 | // Latest Updates 244 | const hoveredTiles = document.querySelectorAll('div.resource-tile-hover'); 245 | hoveredTiles.forEach((tile) => { 246 | const tagsWrapper = tile.querySelector('div.resource-tile_tags'); 247 | if (!tagsWrapper) return; 248 | const tagSpans = tagsWrapper.querySelectorAll('span'); 249 | tagSpans.forEach((span) => { 250 | const name = span.innerText; 251 | if (settings.tags_highlights.hasOwnProperty(name)) { 252 | const highlight = settings.tags_highlights[name]; 253 | span.style.color = highlightColors[highlight].text; 254 | span.style.backgroundColor = highlightColors[highlight].background; 255 | span.style.border = highlightColors[highlight].border; 256 | } 257 | }); 258 | }); 259 | // Thread 260 | const tagLinks = document.querySelectorAll('a.tagItem'); 261 | tagLinks.forEach((link) => { 262 | const name = link.innerText; 263 | if (settings.tags_highlights.hasOwnProperty(name)) { 264 | const highlight = settings.tags_highlights[name]; 265 | link.style.color = highlightColors[highlight].text; 266 | link.style.backgroundColor = highlightColors[highlight].background; 267 | link.style.border = highlightColors[highlight].border; 268 | } 269 | }); 270 | }; 271 | const doUpdate = () => { 272 | injectCustomWebfont(); 273 | removeOldIcons(); 274 | addHrefIcons(); 275 | addPageIcon(); 276 | if (settings.highlight_tags) { 277 | installHighlighterMutationObservers(); 278 | highlightTags(); 279 | } 280 | }; 281 | const installMutationObservers = () => { 282 | const latest = document.getElementById('latest-page_items-wrap'); 283 | if (latest) { 284 | const observer = new MutationObserver(doUpdate); 285 | observer.observe(latest, { attributes: true }); 286 | } 287 | }; 288 | installMutationObservers(); 289 | doUpdate(); 290 | }, 291 | args: [games, settings, rpcURL], 292 | }); 293 | }; 294 | 295 | chrome.webNavigation.onCompleted.addListener( 296 | (details) => { 297 | updateIcons(details.tabId); 298 | }, 299 | { url: [{ hostSuffix: 'f95zone.to' }] } 300 | ); 301 | 302 | // Click on extension icon 303 | chrome.action.onClicked.addListener((tab) => { 304 | addGame(tab.url, tab.id); 305 | }); 306 | 307 | // Context menus 308 | chrome.contextMenus.create({ 309 | id: `add-page-to-f95checker`, 310 | title: `Add this page to F95Checker`, 311 | contexts: ['page'], 312 | documentUrlPatterns: ['*://*.f95zone.to/threads/*'], 313 | }); 314 | chrome.contextMenus.create({ 315 | id: `add-link-to-f95checker`, 316 | title: `Add this link to F95Checker`, 317 | contexts: ['link'], 318 | targetUrlPatterns: ['*://*.f95zone.to/threads/*'], 319 | }); 320 | 321 | chrome.contextMenus.onClicked.addListener((info, tab) => { 322 | switch (info.menuItemId) { 323 | case 'add-link-to-f95checker': 324 | case 'add-page-to-f95checker': 325 | addGame(info.linkUrl || info.pageUrl, tab.id); 326 | break; 327 | } 328 | }); 329 | 330 | setInterval(getData, 5 * 60 * 1000); // 5 minutes 331 | getData(); 332 | -------------------------------------------------------------------------------- /browser/chrome/fonts/mdi-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome/fonts/mdi-webfont.ttf -------------------------------------------------------------------------------- /browser/chrome/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome/icons/128.png -------------------------------------------------------------------------------- /browser/chrome/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome/icons/16.png -------------------------------------------------------------------------------- /browser/chrome/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome/icons/32.png -------------------------------------------------------------------------------- /browser/chrome/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/chrome/icons/64.png -------------------------------------------------------------------------------- /browser/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "F95Checker Browser Addon", 4 | "short_name": "F95Checker", 5 | "author": "WillyJL", 6 | "version": "11.0.2.2", 7 | "description": "Integration between the F95zone forum and the F95Checker app", 8 | "homepage_url": "https://github.com/WillyJL/F95Checker", 9 | "action": { 10 | "default_icon": { 11 | "16": "icons/16.png", 12 | "32": "icons/32.png", 13 | "64": "icons/64.png" 14 | } 15 | }, 16 | "icons": { 17 | "16": "icons/16.png", 18 | "32": "icons/32.png", 19 | "64": "icons/64.png", 20 | "128": "icons/128.png" 21 | }, 22 | "background": { 23 | "service_worker": "extension.js" 24 | }, 25 | "permissions": [ 26 | "scripting", 27 | "activeTab", 28 | "contextMenus", 29 | "webNavigation" 30 | ], 31 | "web_accessible_resources": [ 32 | { 33 | "resources": [ 34 | "fonts/mdi-webfont.ttf" 35 | ], 36 | "matches": [ 37 | "https://f95zone.to/*" 38 | ], 39 | "use_dynamic_url": true 40 | } 41 | ], 42 | "host_permissions": [ 43 | "*://*.f95zone.to/*" 44 | ] 45 | } -------------------------------------------------------------------------------- /browser/firefox.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox.zip -------------------------------------------------------------------------------- /browser/firefox/extension.js: -------------------------------------------------------------------------------- 1 | const rpcPort = 57095; 2 | const rpcURL = `http://127.0.0.1:${rpcPort}`; 3 | let games = []; 4 | let settings = {}; 5 | 6 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 7 | 8 | const rpcCall = async (method, path, body, tabId) => { 9 | if (typeof method !== 'string' || typeof path !== 'string' || (typeof body !== 'string' && body !== null)) { 10 | return {}; 11 | } 12 | try { 13 | const res = await fetch(`${rpcURL}${path}`, { 14 | method: method, 15 | body: body, 16 | }); 17 | if (!res.ok) { 18 | throw res.status; 19 | } 20 | return res; 21 | } catch { 22 | if (tabId) { 23 | chrome.scripting.executeScript({ 24 | target: { tabId: tabId }, 25 | func: () => { 26 | alert( 27 | 'Could not connect to F95Checker!\nIs it open and updated? Is RPC enabled?' 28 | ); 29 | }, 30 | }); 31 | } 32 | } 33 | }; 34 | 35 | const getData = async () => { 36 | let res; 37 | res = await rpcCall('GET', '/games', null); 38 | games = res ? await res.json() : []; 39 | res = await rpcCall('GET', '/settings', null); 40 | settings = res ? await res.json() : { 41 | "icon_glow": true, 42 | "highlight_tags": false, 43 | "tags_highlights": {}, 44 | }; 45 | }; 46 | 47 | const addGame = async (url, tabId) => { 48 | await rpcCall('POST', '/games/add', JSON.stringify([url]), tabId); 49 | await sleep(0.5 * 1000); 50 | await updateIcons(tabId); 51 | }; 52 | 53 | // Add icons for games, reminders, etc. 54 | const updateIcons = async (tabId) => { 55 | await getData(); 56 | chrome.scripting.executeScript({ 57 | target: { tabId: tabId }, 58 | func: (games, settings, rpcURL) => { 59 | const injectCustomWebfont = () => { 60 | const styleTag = document.createElement('style'); 61 | const cssContent = String.raw` 62 | @font-face{ 63 | font-family: "MDI Custom"; 64 | src: url('${chrome.runtime.getURL("fonts/mdi-webfont.ttf")}') format('truetype'); 65 | font-weight: normal; 66 | font-style: normal; 67 | } 68 | .mdi:before { 69 | display: inline-block; 70 | font: normal normal normal 24px/1 "MDI Custom"; 71 | font-size: inherit; 72 | text-rendering: auto; 73 | line-height: inherit; 74 | -webkit-font-smoothing: antialiased; 75 | -moz-osx-font-smoothing: grayscale; 76 | } 77 | .mdi::before { 78 | content: var(--mdi-i); 79 | } 80 | `; 81 | styleTag.appendChild(document.createTextNode(cssContent)); 82 | document.head.appendChild(styleTag); 83 | }; 84 | const extractThreadId = (url) => { 85 | const match = /threads\/(?:(?:[^\.\/]*)\.)?(\d+)/.exec(url); 86 | return match ? parseInt(match[1]) : null; 87 | }; 88 | const createContainer = () => { 89 | const c = document.createElement('div'); 90 | c.classList.add('f95checker-library-icons'); 91 | c.style.display = 'inline-block'; 92 | return c; 93 | }; 94 | const createIcon = (gameId) => { 95 | const icon = document.createElement('i'); 96 | const game = games.find(g => g.id === gameId); 97 | icon.classList.add('mdi'); 98 | icon.style.setProperty('--mdi-i', `'${game.icon}'`); 99 | let tooltiptext = 'This game is present in your F95Checker library!'; 100 | if (game.notes) { 101 | tooltiptext += `\n\nNOTES: ${game.notes}`; 102 | } 103 | icon.setAttribute('title', tooltiptext); 104 | icon.addEventListener('click', () => 105 | alert(tooltiptext) 106 | ); 107 | icon.style.color = game.color; 108 | return [icon, game.color]; 109 | }; 110 | const createNbsp = () => { 111 | const span = document.createElement('span'); 112 | span.style.display = 'inline-block'; 113 | span.innerHTML = ' '; 114 | return span; 115 | }; 116 | const removeOldIcons = () => { 117 | document.querySelectorAll('.f95checker-library-icons').forEach((e) => e.remove()); 118 | }; 119 | const isValidHrefElem = (elem, elemId, pageId) => { 120 | // Ignore Reply and Quote buttons 121 | if (/reply\?.*$/.test(elem.href)) return false; 122 | 123 | // Ignore post navigation 124 | const parent = elem.parentNode 125 | if (/page-.*$/.test(elem.href)) return false; 126 | if (parent && parent.classList.contains('pageNav')) return false; 127 | if (parent && parent.classList.contains('pageNav-page')) return false; 128 | 129 | // Ignore post numbers 130 | const ul = elem.closest('ul') 131 | if (ul && ul.classList.contains('message-attribution-opposite')) return false; 132 | // Ignore links in the OP pointing to the posts in the same thread 133 | if (elem.closest('.message-threadStarterPost') && elemId === pageId) return false; 134 | 135 | // Ignore non-links 136 | if (elem.classList.contains('button')) return false; 137 | if (elem.classList.contains('tabs-tab')) return false; 138 | if (elem.classList.contains('u-concealed')) return false; 139 | 140 | return true; 141 | } 142 | const addHrefIcons = () => { 143 | const pageId = extractThreadId(document.location) 144 | for (const elem of document.querySelectorAll('a[href*="/threads/"]')) { 145 | const elemId = extractThreadId(elem.href); 146 | 147 | if (!elemId || !games.map(g => g.id).includes(elemId)) { 148 | continue; 149 | } 150 | 151 | const isImage = 152 | elem.classList.contains('resource-tile_link') || 153 | elem.parentNode.parentNode.classList.contains('es-slides'); 154 | 155 | if (!isImage && !isValidHrefElem(elem, elemId, pageId)) { 156 | continue; 157 | } 158 | 159 | const container = createContainer(); 160 | const [icon, color] = createIcon(elemId); 161 | container.prepend(icon); 162 | 163 | if (isImage) { 164 | container.style.position = 'absolute'; 165 | container.style.zIndex = '50'; 166 | container.style.left = '5px'; 167 | container.style.top = '5px'; 168 | container.style.width = '28px'; 169 | container.style.textAlign = 'center'; 170 | container.style.background = '#262626'; 171 | container.style.borderRadius = '4px'; 172 | container.style.fontSize = '1.5em'; 173 | if (settings.icon_glow) { 174 | container.style.boxShadow = `0px 0px 30px 30px ${color.slice(0, 7)}bb`; 175 | } 176 | } 177 | 178 | if (!isImage && elem.children.length > 0) { 179 | // Search page 180 | container.style.fontSize = '1.2em'; 181 | container.style.verticalAlign = '-2px'; 182 | const whitespaces = elem.querySelectorAll('span.label-append'); 183 | if(whitespaces.length > 0) { 184 | const lastWhitespace = whitespaces[whitespaces.length - 1]; 185 | lastWhitespace.insertAdjacentElement('afterend', createNbsp()); 186 | lastWhitespace.insertAdjacentElement('afterend', container); 187 | } else if (elem.classList.contains('link--internal')) { 188 | if (elem.querySelector('img[data-src]')) { 189 | continue; 190 | } 191 | elem.insertAdjacentElement('beforebegin', container); 192 | elem.insertAdjacentElement('beforebegin', createNbsp()); 193 | } else { 194 | continue; 195 | } 196 | } else if (elem.classList.contains('resource-tile_link')) { 197 | // To accomodate all tile layouts on latest updates page 198 | const thumb = elem.querySelector('div.resource-tile_thumb'); 199 | thumb.insertAdjacentElement('beforebegin', container); 200 | } else { 201 | // Everywhere else 202 | container.style.fontSize = '1.2em'; 203 | container.style.verticalAlign = '-2px'; 204 | elem.insertAdjacentElement('beforebegin', container); 205 | elem.insertAdjacentElement('beforebegin', createNbsp()); 206 | } 207 | } 208 | }; 209 | const addPageIcon = () => { 210 | const id = extractThreadId(document.location); 211 | const container = createContainer(); 212 | container.style.fontSize = '1.3em'; 213 | container.style.verticalAlign = '-3px'; 214 | const title = document.getElementsByClassName('p-title-value')[0]; 215 | if (title) { 216 | if (games.map(g => g.id).includes(id)) { 217 | const [icon, _] = createIcon(id); 218 | container.prepend(icon); 219 | title.insertBefore( 220 | container, 221 | title.childNodes[title.childNodes.length - 1] 222 | ); 223 | title.insertBefore( 224 | createNbsp(), 225 | title.childNodes[title.childNodes.length - 1] 226 | ); 227 | }; 228 | } 229 | }; 230 | const installHighlighterMutationObservers = () => { 231 | const tiles = document.querySelectorAll('div.resource-tile_body'); 232 | tiles.forEach((tile) => { 233 | const observer = new MutationObserver(highlightTags); 234 | observer.observe(tile, { attributes: true, subtree: true }); 235 | }); 236 | } 237 | const highlightTags = () => { 238 | const highlightColors = { 239 | 1: { text: 'white', background: '#006600', border: '1px solid #ffffff55' }, // Positive 240 | 2: { text: 'white', background: '#990000', border: '1px solid #ffffff55' }, // Negative 241 | 3: { text: 'white', background: '#000000', border: '1px solid #ffffff55' }, // Critical 242 | }; 243 | // Latest Updates 244 | const hoveredTiles = document.querySelectorAll('div.resource-tile-hover'); 245 | hoveredTiles.forEach((tile) => { 246 | const tagsWrapper = tile.querySelector('div.resource-tile_tags'); 247 | if (!tagsWrapper) return; 248 | const tagSpans = tagsWrapper.querySelectorAll('span'); 249 | tagSpans.forEach((span) => { 250 | const name = span.innerText; 251 | if (settings.tags_highlights.hasOwnProperty(name)) { 252 | const highlight = settings.tags_highlights[name]; 253 | span.style.color = highlightColors[highlight].text; 254 | span.style.backgroundColor = highlightColors[highlight].background; 255 | span.style.border = highlightColors[highlight].border; 256 | } 257 | }); 258 | }); 259 | // Thread 260 | const tagLinks = document.querySelectorAll('a.tagItem'); 261 | tagLinks.forEach((link) => { 262 | const name = link.innerText; 263 | if (settings.tags_highlights.hasOwnProperty(name)) { 264 | const highlight = settings.tags_highlights[name]; 265 | link.style.color = highlightColors[highlight].text; 266 | link.style.backgroundColor = highlightColors[highlight].background; 267 | link.style.border = highlightColors[highlight].border; 268 | } 269 | }); 270 | }; 271 | const doUpdate = () => { 272 | injectCustomWebfont(); 273 | removeOldIcons(); 274 | addHrefIcons(); 275 | addPageIcon(); 276 | if (settings.highlight_tags) { 277 | installHighlighterMutationObservers(); 278 | highlightTags(); 279 | } 280 | }; 281 | const installMutationObservers = () => { 282 | const latest = document.getElementById('latest-page_items-wrap'); 283 | if (latest) { 284 | const observer = new MutationObserver(doUpdate); 285 | observer.observe(latest, { attributes: true }); 286 | } 287 | }; 288 | installMutationObservers(); 289 | doUpdate(); 290 | }, 291 | args: [games, settings, rpcURL], 292 | }); 293 | }; 294 | 295 | chrome.webNavigation.onCompleted.addListener( 296 | (details) => { 297 | updateIcons(details.tabId); 298 | }, 299 | { url: [{ hostSuffix: 'f95zone.to' }] } 300 | ); 301 | 302 | // Click on extension icon 303 | chrome.browserAction.onClicked.addListener((tab) => { 304 | addGame(tab.url, tab.id); 305 | }); 306 | 307 | // Context menus 308 | chrome.contextMenus.create({ 309 | id: `add-page-to-f95checker`, 310 | title: `Add this page to F95Checker`, 311 | contexts: ['page'], 312 | documentUrlPatterns: ['*://*.f95zone.to/threads/*'], 313 | }); 314 | chrome.contextMenus.create({ 315 | id: `add-link-to-f95checker`, 316 | title: `Add this link to F95Checker`, 317 | contexts: ['link'], 318 | targetUrlPatterns: ['*://*.f95zone.to/threads/*'], 319 | }); 320 | 321 | chrome.contextMenus.onClicked.addListener((info, tab) => { 322 | switch (info.menuItemId) { 323 | case 'add-link-to-f95checker': 324 | case 'add-page-to-f95checker': 325 | addGame(info.linkUrl || info.pageUrl, tab.id); 326 | break; 327 | } 328 | }); 329 | 330 | setInterval(getData, 5 * 60 * 1000); // 5 minutes 331 | getData(); 332 | -------------------------------------------------------------------------------- /browser/firefox/fonts/mdi-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox/fonts/mdi-webfont.ttf -------------------------------------------------------------------------------- /browser/firefox/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox/icons/128.png -------------------------------------------------------------------------------- /browser/firefox/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox/icons/16.png -------------------------------------------------------------------------------- /browser/firefox/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox/icons/32.png -------------------------------------------------------------------------------- /browser/firefox/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillyJL/F95Checker/17dcae66aecf9ef527a2f0313c39effa4b7be119/browser/firefox/icons/64.png -------------------------------------------------------------------------------- /browser/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "F95Checker Browser Addon", 4 | "short_name": "F95Checker", 5 | "author": "WillyJL", 6 | "version": "11.0.2.2", 7 | "description": "Integration between the F95zone forum and the F95Checker app", 8 | "homepage_url": "https://github.com/WillyJL/F95Checker", 9 | "browser_action": { 10 | "default_icon": { 11 | "16": "icons/16.png", 12 | "32": "icons/32.png", 13 | "64": "icons/64.png" 14 | } 15 | }, 16 | "icons": { 17 | "16": "icons/16.png", 18 | "32": "icons/32.png", 19 | "64": "icons/64.png", 20 | "128": "icons/128.png" 21 | }, 22 | "background": { 23 | "scripts": [ 24 | "extension.js" 25 | ] 26 | }, 27 | "web_accessible_resources": [ 28 | "fonts/mdi-webfont.ttf" 29 | ], 30 | "permissions": [ 31 | "scripting", 32 | "activeTab", 33 | "contextMenus", 34 | "webNavigation", 35 | "*://*.f95zone.to/*" 36 | ] 37 | } -------------------------------------------------------------------------------- /browser/integrated.js: -------------------------------------------------------------------------------- 1 | // Qt WebEngine doesn't support extensions, only injecting basic JavaScript 2 | // This script is therefore a plain js script that doesn't use chrome APIs 3 | // This method however requires injecting into every single webpage 4 | // That can be hit or miss and injecting into the same page twice can happen 5 | // For this reason all top-level const's and let's have been removed to avoid SyntaxError's 6 | // Also now this script doesn't do anything on its own, it only defines the functions 7 | // It is up to the WebView to invoke them when appropriate 8 | 9 | var games = []; 10 | var settings = {}; 11 | 12 | var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 13 | 14 | var rpcCall = async (method, path, body) => { 15 | if (typeof method !== 'string' || typeof path !== 'string' || (typeof body !== 'string' && body !== null)) { 16 | return {}; 17 | } 18 | try { 19 | const res = await (new Promise((resolve) => { 20 | new QWebChannel(qt.webChannelTransport, (channel) => { 21 | channel.objects.rpcproxy.handle(method, path, body, (ret) => { 22 | resolve(new Response(atob(ret.body), ret)); 23 | }); 24 | }); 25 | })); 26 | if (!res.ok) { 27 | throw res.status; 28 | } 29 | return res; 30 | } catch { } 31 | }; 32 | 33 | var getData = async () => { 34 | let res; 35 | res = await rpcCall('GET', '/games', null); 36 | games = res ? await res.json() : []; 37 | res = await rpcCall('GET', '/settings', null); 38 | settings = res ? await res.json() : { 39 | "icon_glow": true, 40 | "highlight_tags": false, 41 | "tags_highlights": {}, 42 | }; 43 | }; 44 | 45 | var addGame = async (url) => { 46 | await rpcCall('POST', '/games/add', JSON.stringify([url])); 47 | await sleep(0.5 * 1000); 48 | await updateIcons(); 49 | }; 50 | 51 | // Add icons for games, reminders, etc. 52 | var updateIcons = async () => { 53 | await getData(); 54 | const font = await (await rpcCall('GET', '/assets/mdi-webfont.ttf', null)).text(); 55 | const font_url = `data:@font/ttf;base64,${btoa(font)}`; 56 | const injectCustomWebfont = () => { 57 | const styleTag = document.createElement('style'); 58 | const cssContent = String.raw` 59 | @font-face{ 60 | font-family: "MDI Custom"; 61 | src: url('${font_url}') format('truetype'); 62 | font-weight: normal; 63 | font-style: normal; 64 | } 65 | .mdi:before { 66 | display: inline-block; 67 | font: normal normal normal 24px/1 "MDI Custom"; 68 | font-size: inherit; 69 | text-rendering: auto; 70 | line-height: inherit; 71 | -webkit-font-smoothing: antialiased; 72 | -moz-osx-font-smoothing: grayscale; 73 | } 74 | .mdi::before { 75 | content: var(--mdi-i); 76 | } 77 | `; 78 | styleTag.appendChild(document.createTextNode(cssContent)); 79 | document.head.appendChild(styleTag); 80 | }; 81 | const extractThreadId = (url) => { 82 | const match = /threads\/(?:(?:[^\.\/]*)\.)?(\d+)/.exec(url); 83 | return match ? parseInt(match[1]) : null; 84 | }; 85 | const createContainer = () => { 86 | const c = document.createElement('div'); 87 | c.classList.add('f95checker-library-icons'); 88 | c.style.display = 'inline-block'; 89 | return c; 90 | }; 91 | const createIcon = (gameId) => { 92 | const icon = document.createElement('i'); 93 | const game = games.find(g => g.id === gameId); 94 | icon.classList.add('mdi'); 95 | icon.style.setProperty('--mdi-i', `'${game.icon}'`); 96 | let tooltiptext = 'This game is present in your F95Checker library!'; 97 | if (game.notes) { 98 | tooltiptext += `\n\nNOTES: ${game.notes}`; 99 | } 100 | icon.setAttribute('title', tooltiptext); 101 | icon.addEventListener('click', () => 102 | alert(tooltiptext) 103 | ); 104 | icon.style.color = game.color; 105 | return [icon, game.color]; 106 | }; 107 | const createNbsp = () => { 108 | const span = document.createElement('span'); 109 | span.style.display = 'inline-block'; 110 | span.innerHTML = ' '; 111 | return span; 112 | }; 113 | const removeOldIcons = () => { 114 | document.querySelectorAll('.f95checker-library-icons').forEach((e) => e.remove()); 115 | }; 116 | const isValidHrefElem = (elem, elemId, pageId) => { 117 | // Ignore Reply and Quote buttons 118 | if (/reply\?.*$/.test(elem.href)) return false; 119 | 120 | // Ignore post navigation 121 | const parent = elem.parentNode 122 | if (/page-.*$/.test(elem.href)) return false; 123 | if (parent && parent.classList.contains('pageNav')) return false; 124 | if (parent && parent.classList.contains('pageNav-page')) return false; 125 | 126 | // Ignore post numbers 127 | const ul = elem.closest('ul') 128 | if (ul && ul.classList.contains('message-attribution-opposite')) return false; 129 | // Ignore links in the OP pointing to the posts in the same thread 130 | if (elem.closest('.message-threadStarterPost') && elemId === pageId) return false; 131 | 132 | // Ignore non-links 133 | if (elem.classList.contains('button')) return false; 134 | if (elem.classList.contains('tabs-tab')) return false; 135 | if (elem.classList.contains('u-concealed')) return false; 136 | 137 | return true; 138 | } 139 | const addHrefIcons = () => { 140 | const pageId = extractThreadId(document.location) 141 | for (const elem of document.querySelectorAll('a[href*="/threads/"]')) { 142 | const elemId = extractThreadId(elem.href); 143 | 144 | if (!elemId || !games.map(g => g.id).includes(elemId)) { 145 | continue; 146 | } 147 | 148 | const isImage = 149 | elem.classList.contains('resource-tile_link') || 150 | elem.parentNode.parentNode.classList.contains('es-slides'); 151 | 152 | if (!isImage && !isValidHrefElem(elem, elemId, pageId)) { 153 | continue; 154 | } 155 | 156 | const container = createContainer(); 157 | const [icon, color] = createIcon(elemId); 158 | container.prepend(icon); 159 | 160 | if (isImage) { 161 | container.style.position = 'absolute'; 162 | container.style.zIndex = '50'; 163 | container.style.left = '5px'; 164 | container.style.top = '5px'; 165 | container.style.width = '28px'; 166 | container.style.textAlign = 'center'; 167 | container.style.background = '#262626'; 168 | container.style.borderRadius = '4px'; 169 | container.style.fontSize = '1.5em'; 170 | if (settings.icon_glow) { 171 | container.style.boxShadow = `0px 0px 30px 30px ${color.slice(0, 7)}bb`; 172 | } 173 | } 174 | 175 | if (!isImage && elem.children.length > 0) { 176 | // Search page 177 | container.style.fontSize = '1.2em'; 178 | container.style.verticalAlign = '-2px'; 179 | const whitespaces = elem.querySelectorAll('span.label-append'); 180 | if(whitespaces.length > 0) { 181 | const lastWhitespace = whitespaces[whitespaces.length - 1]; 182 | lastWhitespace.insertAdjacentElement('afterend', createNbsp()); 183 | lastWhitespace.insertAdjacentElement('afterend', container); 184 | } else if (elem.classList.contains('link--internal')) { 185 | if (elem.querySelector('img[data-src]')) { 186 | continue; 187 | } 188 | elem.insertAdjacentElement('beforebegin', container); 189 | elem.insertAdjacentElement('beforebegin', createNbsp()); 190 | } else { 191 | continue; 192 | } 193 | } else if (elem.classList.contains('resource-tile_link')) { 194 | // To accomodate all tile layouts on latest updates page 195 | const thumb = elem.querySelector('div.resource-tile_thumb'); 196 | thumb.insertAdjacentElement('beforebegin', container); 197 | } else { 198 | // Everywhere else 199 | container.style.fontSize = '1.2em'; 200 | container.style.verticalAlign = '-2px'; 201 | elem.insertAdjacentElement('beforebegin', container); 202 | elem.insertAdjacentElement('beforebegin', createNbsp()); 203 | } 204 | } 205 | }; 206 | const addPageIcon = () => { 207 | const id = extractThreadId(document.location); 208 | const container = createContainer(); 209 | container.style.fontSize = '1.3em'; 210 | container.style.verticalAlign = '-3px'; 211 | const title = document.getElementsByClassName('p-title-value')[0]; 212 | if (title) { 213 | if (games.map(g => g.id).includes(id)) { 214 | const [icon, _] = createIcon(id); 215 | container.prepend(icon); 216 | title.insertBefore( 217 | container, 218 | title.childNodes[title.childNodes.length - 1] 219 | ); 220 | title.insertBefore( 221 | createNbsp(), 222 | title.childNodes[title.childNodes.length - 1] 223 | ); 224 | }; 225 | } 226 | }; 227 | const installHighlighterMutationObservers = () => { 228 | const tiles = document.querySelectorAll('div.resource-tile_body'); 229 | tiles.forEach((tile) => { 230 | const observer = new MutationObserver(highlightTags); 231 | observer.observe(tile, { attributes: true, subtree: true }); 232 | }); 233 | } 234 | const highlightTags = () => { 235 | const highlightColors = { 236 | 1: { text: 'white', background: '#006600', border: '1px solid #ffffff55' }, // Positive 237 | 2: { text: 'white', background: '#990000', border: '1px solid #ffffff55' }, // Negative 238 | 3: { text: 'white', background: '#000000', border: '1px solid #ffffff55' }, // Critical 239 | }; 240 | // Latest Updates 241 | const hoveredTiles = document.querySelectorAll('div.resource-tile-hover'); 242 | hoveredTiles.forEach((tile) => { 243 | const tagsWrapper = tile.querySelector('div.resource-tile_tags'); 244 | if (!tagsWrapper) return; 245 | const tagSpans = tagsWrapper.querySelectorAll('span'); 246 | tagSpans.forEach((span) => { 247 | const name = span.innerText; 248 | if (settings.tags_highlights.hasOwnProperty(name)) { 249 | const highlight = settings.tags_highlights[name]; 250 | span.style.color = highlightColors[highlight].text; 251 | span.style.backgroundColor = highlightColors[highlight].background; 252 | span.style.border = highlightColors[highlight].border; 253 | } 254 | }); 255 | }); 256 | // Thread 257 | const tagLinks = document.querySelectorAll('a.tagItem'); 258 | tagLinks.forEach((link) => { 259 | const name = link.innerText; 260 | if (settings.tags_highlights.hasOwnProperty(name)) { 261 | const highlight = settings.tags_highlights[name]; 262 | link.style.color = highlightColors[highlight].text; 263 | link.style.backgroundColor = highlightColors[highlight].background; 264 | link.style.border = highlightColors[highlight].border; 265 | } 266 | }); 267 | }; 268 | const doUpdate = () => { 269 | injectCustomWebfont(); 270 | removeOldIcons(); 271 | addHrefIcons(); 272 | addPageIcon(); 273 | if (settings.highlight_tags) { 274 | installHighlighterMutationObservers(); 275 | highlightTags(); 276 | } 277 | }; 278 | const installMutationObservers = () => { 279 | const latest = document.getElementById('latest-page_items-wrap'); 280 | if (latest) { 281 | const observer = new MutationObserver(doUpdate); 282 | observer.observe(latest, { attributes: true }); 283 | } 284 | }; 285 | installMutationObservers(); 286 | doUpdate(); 287 | }; 288 | -------------------------------------------------------------------------------- /common/meta.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import sys 4 | 5 | version = "11.1" 6 | release = False 7 | build_number = 0 8 | version_name = f"{version}{'' if release else ' beta'}{'' if release or not build_number else ' ' + str(build_number)}" 9 | rpc_port = 57095 10 | rpc_url = f"http://127.0.0.1:{rpc_port}" 11 | 12 | frozen = getattr(sys, "frozen", False) 13 | self_path = (pathlib.Path(sys.executable) if frozen else pathlib.Path(__file__).parent).parent 14 | debug = not (frozen or release) 15 | if not sys.stdout or not sys.stderr or os.devnull in (sys.stdout.name, sys.stderr.name): 16 | debug = False 17 | -------------------------------------------------------------------------------- /external/async_thread.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/183cb7134e940db1cfab72480e95a357 2 | import asyncio 3 | import threading 4 | import time 5 | import typing 6 | 7 | loop: asyncio.BaseEventLoop = None 8 | thread: threading.Thread = None 9 | done_callback: typing.Callable = None 10 | 11 | 12 | def setup(): 13 | global loop, thread 14 | 15 | loop = asyncio.new_event_loop() 16 | 17 | def run_loop(): 18 | asyncio.set_event_loop(loop) 19 | loop.run_forever() 20 | 21 | thread = threading.Thread(target=run_loop, daemon=True) 22 | thread.start() 23 | 24 | 25 | def run(coroutine: typing.Coroutine): 26 | future = asyncio.run_coroutine_threadsafe(coroutine, loop) 27 | if done_callback: 28 | future.add_done_callback(done_callback) 29 | return future 30 | 31 | 32 | def wait(coroutine: typing.Coroutine): 33 | future = run(coroutine) 34 | while future.running(): 35 | time.sleep(0.1) 36 | if exception := future.exception(): 37 | raise exception 38 | return future.result() 39 | 40 | 41 | # Example usage 42 | if __name__ == "__main__": 43 | import async_thread # This script is designed as a module you import 44 | async_thread.setup() 45 | 46 | import random 47 | async def wait_and_say_hello(num): 48 | await asyncio.sleep(random.random()) 49 | print(f"Hello {num}!") 50 | 51 | for i in range(10): 52 | async_thread.run(wait_and_say_hello(i)) 53 | 54 | # You can also wait for the task to complete: 55 | for i in range(10): 56 | async_thread.wait(wait_and_say_hello(i)) 57 | -------------------------------------------------------------------------------- /external/error.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/f733c960c6b0d2284bcbee0316f88878 2 | import sys 3 | import traceback as _traceback 4 | 5 | 6 | def traceback(exc: Exception = None): 7 | # Full error traceback with line numbers and previews 8 | if exc: 9 | exc_info = type(exc), exc, exc.__traceback__ 10 | else: 11 | exc_info = sys.exc_info() 12 | tb_lines = _traceback.format_exception(*exc_info) 13 | tb = "".join(tb_lines) 14 | return tb 15 | 16 | 17 | def text(exc: Exception = None): 18 | # Short error text like "ExcName: exception text" 19 | exc = exc or sys.exc_info()[1] 20 | return f"{type(exc).__name__}: {str(exc) or 'No further details'}" 21 | 22 | 23 | # Example usage 24 | if __name__ == "__main__": 25 | import error # This script is designed as a module you import 26 | 27 | try: 28 | 0/0 29 | except Exception as exc: 30 | # can be used with no arguments inside except blocks 31 | print(error.text()) 32 | print(error.traceback()) 33 | # or if you have the exception object you can pass that 34 | print(error.text(exc)) 35 | print(error.traceback(exc)) 36 | -------------------------------------------------------------------------------- /external/filepicker.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/82137493896d385a74d148534691b6e1 2 | import os 3 | import pathlib 4 | import string 5 | import sys 6 | import typing 7 | 8 | import glfw 9 | import imgui 10 | 11 | from modules import ( # added 12 | callbacks, # added 13 | globals, # added 14 | icons, # added 15 | utils, # added 16 | ) # added 17 | 18 | dir_icon = f"{icons.folder_outline} " # changed 19 | file_icon = f"{icons.file_outline} " # changed 20 | up_icon = icons.arrow_up # changed 21 | refresh_icon = icons.refresh # changed 22 | cancel_icon = f"{icons.cancel} Cancel" # changed 23 | open_icon = f"{icons.folder_open_outline} Open" # changed 24 | ok_icon = f"{icons.check} Ok" # changed 25 | 26 | 27 | class FilePicker: 28 | default_flags = ( 29 | imgui.WINDOW_NO_MOVE | 30 | imgui.WINDOW_NO_RESIZE | 31 | imgui.WINDOW_NO_COLLAPSE | 32 | imgui.WINDOW_NO_SAVED_SETTINGS | 33 | imgui.WINDOW_ALWAYS_AUTO_RESIZE 34 | ) 35 | 36 | __slots__ = ( 37 | "current", 38 | "title", 39 | "active", 40 | "elapsed", 41 | "buttons", 42 | "callback", 43 | "selected", 44 | "filter_box_text", 45 | "update_filter", 46 | "items", 47 | "dir_picker", 48 | "dir", 49 | "flags", 50 | "windows", 51 | "drives", 52 | "current_drive", 53 | ) 54 | 55 | def __init__( 56 | self, 57 | title="File picker", 58 | dir_picker=False, 59 | start_dir: str | pathlib.Path = None, 60 | callback: typing.Callable = None, 61 | buttons: list[str] = [], 62 | custom_popup_flags=0 63 | ): 64 | self.current = 0 65 | self.title = title 66 | self.active = True 67 | self.elapsed = 0.0 68 | self.buttons = buttons 69 | self.callback = callback 70 | self.selected: str = None 71 | self.filter_box_text = "" 72 | self.update_filter = False 73 | self.items: list[str] = [] 74 | self.dir_picker = dir_picker 75 | self.dir: pathlib.Path = None 76 | self.flags = custom_popup_flags or self.default_flags 77 | self.windows = sys.platform.startswith("win") 78 | if self.windows: 79 | self.drives: list[str] = [] 80 | self.current_drive = 0 81 | self.goto(start_dir or os.getcwd()) 82 | 83 | def goto(self, dir: str | pathlib.Path): 84 | dir = pathlib.Path(dir) 85 | if dir.is_file(): 86 | dir = dir.parent 87 | if dir.is_dir(): 88 | self.dir = dir 89 | elif self.dir is None: 90 | self.dir = pathlib.Path(os.getcwd()) 91 | self.dir = self.dir.absolute() 92 | self.current = -1 93 | self.filter_box_text = "" 94 | self.refresh() 95 | 96 | def refresh(self): 97 | if self.current != -1: 98 | selected = self.items[self.current] 99 | else: 100 | selected = "" 101 | self.items.clear() 102 | try: 103 | items = list(filter(lambda item: self.filter_box_text.lower() in item.name.lower(), self.dir.iterdir())) 104 | if len(items) > 0: 105 | items.sort(key=lambda item: item.name.lower()) # Sort alphabetically 106 | items.sort(key=lambda item: item.is_dir(), reverse=True) # Sort dirs first 107 | for item in items: 108 | self.items.append((dir_icon if item.is_dir() else file_icon) + item.name) 109 | else: 110 | self.items.append("No items match your filter!" if self.filter_box_text else "This folder is empty!") 111 | except Exception: 112 | self.items.append("Cannot open this folder!") 113 | if self.windows: 114 | self.drives.clear() 115 | i = -1 116 | for letter in string.ascii_uppercase: 117 | drive = f"{letter}:\\" 118 | try: 119 | drive_exists = pathlib.Path(drive).exists() 120 | except PermissionError: 121 | drive_exists = True 122 | except Exception: 123 | drive_exists = False 124 | if drive_exists: 125 | i += 1 126 | self.drives.append(drive) 127 | if str(self.dir).startswith(drive): 128 | self.current_drive = i 129 | if selected in self.items: 130 | self.current = self.items.index(selected) 131 | else: 132 | self.current = -1 133 | 134 | def tick(self, popup_uuid: str = ""): 135 | if not self.active: 136 | return 0, True 137 | io = imgui.get_io() 138 | style = imgui.get_style() 139 | # Auto refresh 140 | self.elapsed += io.delta_time 141 | if self.elapsed > 2.0 or self.update_filter: 142 | self.elapsed = 0.0 143 | self.refresh() 144 | # Setup popup 145 | label = self.title + "###popup_" + popup_uuid # changed 146 | if not imgui.is_popup_open(label): 147 | imgui.open_popup(label) 148 | closed = False # added 149 | opened = 1 # added 150 | size = io.display_size 151 | imgui.set_next_window_position(size.x / 2, size.y / 2, pivot_x=0.5, pivot_y=0.5) 152 | if imgui.begin_popup_modal(label, True, flags=self.flags)[0]: 153 | imgui.begin_group() 154 | # Up button 155 | if imgui.button(up_icon): 156 | self.goto(self.dir.parent) 157 | # Drive selector 158 | if self.windows: 159 | imgui.same_line() 160 | imgui.set_next_item_width(imgui.get_font_size() * 4) 161 | changed, value = imgui.combo("###drive_selector", self.current_drive, self.drives) 162 | if changed: 163 | self.goto(self.drives[value]) 164 | # Location bar 165 | imgui.same_line() 166 | imgui.set_next_item_width(size.x * 0.7) 167 | confirmed, dir = imgui.input_text("###location_bar", str(self.dir), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) 168 | if imgui.begin_popup_context_item(f"###location_context"): # added 169 | if imgui.selectable(f"{icons.content_copy} Copy", False)[0]: # added 170 | callbacks.clipboard_copy(dir) # added 171 | if imgui.selectable(f"{icons.content_paste} Paste", False)[0] and (clip := callbacks.clipboard_paste()): # added 172 | dir = clip # added 173 | confirmed = True # added 174 | imgui.end_popup() # added 175 | if confirmed: 176 | self.goto(dir) 177 | # Refresh button 178 | imgui.same_line() 179 | if imgui.button(refresh_icon): 180 | self.refresh() 181 | imgui.end_group() 182 | width = imgui.get_item_rect_size().x 183 | 184 | # Main list 185 | imgui.set_next_item_width(width) 186 | imgui.push_style_color(imgui.COLOR_HEADER, *style.colors[imgui.COLOR_BUTTON_HOVERED]) # added 187 | _, value = imgui.listbox(f"###file_list", self.current, self.items, (size.y * 0.65) / imgui.get_frame_height()) 188 | imgui.pop_style_color() # added 189 | if value != -1: 190 | self.current = min(max(value, 0), len(self.items) - 1) 191 | item = self.items[self.current] 192 | is_dir = item.startswith(dir_icon) 193 | is_file = item.startswith(file_icon) 194 | if imgui.is_item_hovered() and imgui.is_mouse_double_clicked(): 195 | if is_dir: 196 | self.goto(self.dir / item[len(dir_icon):]) 197 | elif is_file and not self.dir_picker: 198 | self.selected = str(self.dir / item[len(file_icon):]) 199 | imgui.close_current_popup() 200 | closed = True # added 201 | else: 202 | is_dir = True 203 | is_file = False 204 | 205 | # Cancel button 206 | if imgui.button(cancel_icon): 207 | imgui.close_current_popup() 208 | closed = True 209 | # Custom buttons 210 | for button in self.buttons: 211 | imgui.same_line() 212 | if imgui.button(button): 213 | self.selected = button 214 | imgui.close_current_popup() 215 | closed = True # added 216 | # Open button 217 | imgui.same_line() 218 | if value == -1 or not is_dir: 219 | imgui.internal.push_item_flag(imgui.internal.ITEM_DISABLED, True) 220 | imgui.push_style_var(imgui.STYLE_ALPHA, style.alpha * 0.5) 221 | if imgui.button(open_icon): 222 | self.goto(self.dir / item[len(dir_icon if self.dir_picker else file_icon):]) 223 | if value == -1 or not is_dir: 224 | imgui.internal.pop_item_flag() 225 | imgui.pop_style_var() 226 | # Ok button 227 | imgui.same_line() 228 | if not (is_file and not self.dir_picker) and not (is_dir and self.dir_picker): 229 | imgui.internal.push_item_flag(imgui.internal.ITEM_DISABLED, True) 230 | imgui.push_style_var(imgui.STYLE_ALPHA, style.alpha * 0.5) 231 | if imgui.button(ok_icon): 232 | if value == -1: 233 | self.selected = str(self.dir) 234 | else: 235 | self.selected = str(self.dir / item[len(dir_icon if self.dir_picker else file_icon):]) 236 | imgui.close_current_popup() 237 | closed = True # added 238 | if not (is_file and not self.dir_picker) and not (is_dir and self.dir_picker): 239 | imgui.internal.pop_item_flag() 240 | imgui.pop_style_var() 241 | # Selected text 242 | imgui.same_line() 243 | prev_pos_x = imgui.get_cursor_pos_x() 244 | if (is_file and not self.dir_picker) or (is_dir and self.dir_picker): 245 | if value == -1: 246 | imgui.text(f"Selected: {self.dir.name}") 247 | else: 248 | imgui.text(f"Selected: {item[len(dir_icon if self.dir_picker else file_icon):]}") 249 | # Filter bar 250 | if imgui.is_topmost() and not imgui.is_any_item_active() and (globals.gui.input_chars or any(io.keys_down)): # added 251 | if imgui.is_key_pressed(glfw.KEY_BACKSPACE): # added 252 | self.filter_box_text = self.filter_box_text[:-1] # added 253 | if globals.gui.input_chars: # added 254 | globals.gui.repeat_chars = True # added 255 | imgui.set_keyboard_focus_here() # added 256 | imgui.same_line() 257 | new_pos_x = prev_pos_x + width * 0.5 258 | imgui.set_cursor_pos_x(new_pos_x) 259 | imgui.set_next_item_width(width - new_pos_x + 2 * style.item_spacing.x) 260 | changed, self.filter_box_text = imgui.input_text_with_hint("###filterbar", "Filter...", self.filter_box_text) # changed 261 | setter_extra = lambda _=None: setattr(self, "update_filter", True) # added 262 | if changed: # added 263 | setter_extra() # added 264 | if imgui.begin_popup_context_item(f"###filtercontext"): # added 265 | utils.text_context(self, "filter_box_text", setter_extra, no_icons=True) # added 266 | imgui.end_popup() # added 267 | 268 | closed = closed or utils.close_weak_popup() # added 269 | # imgui.end_popup() # removed 270 | else: # added 271 | opened = 0 # added 272 | closed = True # added 273 | if closed: # changed 274 | if self.callback: 275 | self.callback(self.selected) 276 | self.active = False 277 | return opened, closed # added 278 | 279 | 280 | class DirPicker(FilePicker): 281 | def __init__( 282 | self, 283 | title="Directory picker", 284 | start_dir: str | pathlib.Path = None, 285 | callback: typing.Callable = None, 286 | buttons: list[str] = [], 287 | custom_popup_flags=0 288 | ): 289 | super().__init__( 290 | title=title, 291 | dir_picker=True, 292 | start_dir=start_dir, 293 | callback=callback, 294 | buttons=buttons, 295 | custom_popup_flags=custom_popup_flags 296 | ) 297 | 298 | 299 | # Example usage 300 | if __name__ == "__main__": 301 | global path 302 | path = "" 303 | current_filepicker = None 304 | while True: # Your main window draw loop 305 | with imgui.begin("Example filepicker"): 306 | imgui.text("Path: " + path) 307 | if imgui.button("Pick a new file"): 308 | # Create the filepicker 309 | def callback(selected): 310 | global path 311 | path = selected 312 | current_filepicker = FilePicker("Select a file!", callback=callback) 313 | if current_filepicker: 314 | # Draw filepicker every frame 315 | current_filepicker.tick() 316 | if not current_filepicker.active: 317 | current_filepicker = None 318 | -------------------------------------------------------------------------------- /external/imgui_glfw.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This is a modified version of imgui.integrations.glfw, which fixes cursor position calculation and behavior when unfocused 3 | # Full credits to original authors: https://github.com/pyimgui/pyimgui/blob/master/imgui/integrations/glfw.py 4 | 5 | from __future__ import absolute_import 6 | 7 | import glfw 8 | import imgui 9 | 10 | from imgui.integrations import compute_fb_scale 11 | from imgui.integrations.opengl import ProgrammablePipelineRenderer 12 | 13 | class GlfwRenderer(ProgrammablePipelineRenderer): 14 | def __init__(self, window, attach_callbacks:bool=True): 15 | super(GlfwRenderer, self).__init__() 16 | self.window = window 17 | 18 | if attach_callbacks: 19 | glfw.set_key_callback(self.window, self.keyboard_callback) 20 | glfw.set_cursor_pos_callback(self.window, self.mouse_callback) 21 | glfw.set_window_size_callback(self.window, self.resize_callback) 22 | glfw.set_char_callback(self.window, self.char_callback) 23 | glfw.set_scroll_callback(self.window, self.scroll_callback) 24 | 25 | self.io.display_size = glfw.get_framebuffer_size(self.window) 26 | self.io.get_clipboard_text_fn = self._get_clipboard_text 27 | self.io.set_clipboard_text_fn = self._set_clipboard_text 28 | 29 | self._map_keys() 30 | self._gui_time = None 31 | 32 | def _get_clipboard_text(self): 33 | return glfw.get_clipboard_string(self.window) 34 | 35 | def _set_clipboard_text(self, text): 36 | glfw.set_clipboard_string(self.window, text) 37 | 38 | def _map_keys(self): 39 | key_map = self.io.key_map 40 | 41 | key_map[imgui.KEY_TAB] = glfw.KEY_TAB 42 | key_map[imgui.KEY_LEFT_ARROW] = glfw.KEY_LEFT 43 | key_map[imgui.KEY_RIGHT_ARROW] = glfw.KEY_RIGHT 44 | key_map[imgui.KEY_UP_ARROW] = glfw.KEY_UP 45 | key_map[imgui.KEY_DOWN_ARROW] = glfw.KEY_DOWN 46 | key_map[imgui.KEY_PAGE_UP] = glfw.KEY_PAGE_UP 47 | key_map[imgui.KEY_PAGE_DOWN] = glfw.KEY_PAGE_DOWN 48 | key_map[imgui.KEY_HOME] = glfw.KEY_HOME 49 | key_map[imgui.KEY_END] = glfw.KEY_END 50 | key_map[imgui.KEY_INSERT] = glfw.KEY_INSERT 51 | key_map[imgui.KEY_DELETE] = glfw.KEY_DELETE 52 | key_map[imgui.KEY_BACKSPACE] = glfw.KEY_BACKSPACE 53 | key_map[imgui.KEY_SPACE] = glfw.KEY_SPACE 54 | key_map[imgui.KEY_ENTER] = glfw.KEY_ENTER 55 | key_map[imgui.KEY_ESCAPE] = glfw.KEY_ESCAPE 56 | key_map[imgui.KEY_PAD_ENTER] = glfw.KEY_KP_ENTER 57 | key_map[imgui.KEY_A] = glfw.KEY_A 58 | key_map[imgui.KEY_C] = glfw.KEY_C 59 | key_map[imgui.KEY_V] = glfw.KEY_V 60 | key_map[imgui.KEY_X] = glfw.KEY_X 61 | key_map[imgui.KEY_Y] = glfw.KEY_Y 62 | key_map[imgui.KEY_Z] = glfw.KEY_Z 63 | 64 | def keyboard_callback(self, window, key, scancode, action, mods): 65 | # perf: local for faster access 66 | io = self.io 67 | 68 | if action == glfw.PRESS: 69 | io.keys_down[key] = True 70 | elif action == glfw.RELEASE: 71 | io.keys_down[key] = False 72 | 73 | io.key_ctrl = ( 74 | io.keys_down[glfw.KEY_LEFT_CONTROL] or 75 | io.keys_down[glfw.KEY_RIGHT_CONTROL] 76 | ) 77 | 78 | io.key_alt = ( 79 | io.keys_down[glfw.KEY_LEFT_ALT] or 80 | io.keys_down[glfw.KEY_RIGHT_ALT] 81 | ) 82 | 83 | io.key_shift = ( 84 | io.keys_down[glfw.KEY_LEFT_SHIFT] or 85 | io.keys_down[glfw.KEY_RIGHT_SHIFT] 86 | ) 87 | 88 | io.key_super = ( 89 | io.keys_down[glfw.KEY_LEFT_SUPER] or 90 | io.keys_down[glfw.KEY_RIGHT_SUPER] 91 | ) 92 | 93 | def char_callback(self, window, char): 94 | io = imgui.get_io() 95 | 96 | if 0 < char < 0x10000: 97 | io.add_input_character(char) 98 | 99 | def resize_callback(self, window, width, height): 100 | self.io.display_size = width, height 101 | 102 | def mouse_callback(self, *args, **kwargs): 103 | pass 104 | 105 | def scroll_callback(self, window, x_offset, y_offset): 106 | self.io.mouse_wheel_horizontal = x_offset 107 | self.io.mouse_wheel = y_offset 108 | 109 | def process_inputs(self): 110 | io = imgui.get_io() 111 | 112 | window_size = glfw.get_window_size(self.window) 113 | fb_size = glfw.get_framebuffer_size(self.window) 114 | 115 | io.display_size = window_size 116 | io.display_fb_scale = compute_fb_scale(window_size, fb_size) 117 | io.delta_time = 1.0/60 118 | 119 | io.mouse_pos = [round(c) for c in glfw.get_cursor_pos(self.window)] 120 | 121 | io.mouse_down[0] = glfw.get_mouse_button(self.window, 0) 122 | io.mouse_down[1] = glfw.get_mouse_button(self.window, 1) 123 | io.mouse_down[2] = glfw.get_mouse_button(self.window, 2) 124 | 125 | current_time = glfw.get_time() 126 | 127 | if self._gui_time: 128 | self.io.delta_time = current_time - self._gui_time 129 | else: 130 | self.io.delta_time = 1. / 60. 131 | if(io.delta_time <= 0.0): io.delta_time = 1./ 1000. 132 | 133 | self._gui_time = current_time 134 | -------------------------------------------------------------------------------- /external/ratingwidget.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/e9e9dac70b7970b6ee12fcf52b9b8f11 2 | import imgui 3 | 4 | from modules import icons # added 5 | 6 | filled_icon = icons.star # changed 7 | empty_icon = icons.star_outline # changed 8 | 9 | 10 | def ratingwidget(id: str, current: int, num_stars=5, *args, **kwargs): 11 | value = current 12 | accent_col = imgui.style.colors[imgui.COLOR_BUTTON_HOVERED] # added 13 | imgui.push_style_color(imgui.COLOR_BUTTON, 0, 0, 0, 0) 14 | imgui.push_style_color(imgui.COLOR_BUTTON_ACTIVE, 0, 0, 0, 0) 15 | imgui.push_style_color(imgui.COLOR_BUTTON_HOVERED, 0, 0, 0, 0) 16 | imgui.push_style_var(imgui.STYLE_FRAME_PADDING, (0, 0)) 17 | imgui.push_style_var(imgui.STYLE_ITEM_SPACING, (0, 0)) 18 | imgui.push_style_var(imgui.STYLE_FRAME_BORDERSIZE, 0) 19 | for i in range(1, num_stars + 1): 20 | if i <= current: 21 | label = filled_icon 22 | imgui.push_style_color(imgui.COLOR_TEXT, *accent_col) # added 23 | else: 24 | label = empty_icon 25 | if imgui.small_button(label, *args, **kwargs): # changed 26 | value = i if current != i else 0 # Clicking the current value resets the rating to 0 27 | if i <= current: # added 28 | imgui.pop_style_color() # added 29 | imgui.same_line() 30 | value = min(max(value, 0), num_stars) 31 | imgui.pop_style_color(3) 32 | imgui.pop_style_var(3) 33 | imgui.dummy(0, 0) 34 | return value != current, value 35 | 36 | 37 | # Example usage 38 | if __name__ == "__main__": 39 | rating_5 = 0 40 | rating_10 = 0 41 | 42 | # Note: you will need material design icons or another icon font for this: 43 | imgui.get_io().fonts.add_font_from_file_ttf( 44 | "materialdesignicons-webfont.ttf", 16, 45 | font_config=imgui.core.FontConfig(merge_mode=True), 46 | glyph_ranges=imgui.core.GlyphRanges([0xf0000, 0xf2000, 0]) 47 | ) 48 | impl.refresh_font_texture() 49 | 50 | while True: # Your main window draw loop 51 | with imgui.begin("Example rating"): 52 | 53 | imgui.text("With 5 stars:") 54 | imgui.same_line() 55 | changed, rating_5 = ratingwidget("5_stars", rating_5) # Default star count is 5 56 | if changed: 57 | imgui.same_line() 58 | imgui.text(f"You set me to {rating_5} stars!") 59 | 60 | imgui.text("With 10 stars:") 61 | imgui.same_line() 62 | _, rating_10 = ratingwidget("10_stars", rating_10, num_stars=10) 63 | -------------------------------------------------------------------------------- /external/singleton.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/2473ab16e27d4c8d8c0c4d7bcb81a5ee 2 | import os 3 | 4 | 5 | class Singleton: 6 | __slots__ = ("lock", "running",) 7 | 8 | def __init__(self, app_id: str): 9 | if os.name == 'nt': 10 | # Requirement: pip install pywin32 11 | import win32api, win32event, winerror 12 | self.lock = win32event.CreateMutex(None, False, app_id) 13 | self.running = (win32api.GetLastError() == winerror.ERROR_ALREADY_EXISTS) 14 | else: 15 | import fcntl 16 | self.lock = open(f"/tmp/Singleton-{app_id}.lock", 'wb') 17 | try: 18 | fcntl.lockf(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB) 19 | self.running = False 20 | except IOError: 21 | self.running = True 22 | 23 | if self.running: 24 | raise RuntimeError(f"Another instance of {app_id} is already running!") 25 | 26 | def release(self): 27 | if self.lock: 28 | try: 29 | if os.name == 'nt': 30 | win32api.CloseHandle(self.lock) 31 | else: 32 | os.close(self.lock) 33 | except Exception: 34 | pass 35 | 36 | def __del__(self): 37 | self.release() 38 | 39 | singletons: dict[Singleton] = {} 40 | 41 | 42 | def lock(app_id: str): 43 | if app_id in singletons: 44 | raise FileExistsError("This app id is already locked to this process!") 45 | singletons[app_id] = Singleton(app_id) 46 | 47 | 48 | def release(app_id: str): 49 | if app_id not in singletons: 50 | raise FileNotFoundError("This app id is not locked to this process!") 51 | singletons[app_id].release() 52 | 53 | 54 | # Example usage 55 | if __name__ == "__main__": 56 | import singleton # This script is designed as a module you import 57 | singleton.lock("SomeCoolProgram") 58 | 59 | print("Do some very cool stuff") 60 | 61 | # Release usually happens automatically on exit, but call this to be sure 62 | singleton.release("SomeCoolProgram") 63 | 64 | # Credits for the basic functionality go to this answer on stackoverflow https://stackoverflow.com/a/66002139 65 | -------------------------------------------------------------------------------- /external/sync_thread.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/bb410bcc761f8bf5649180f22b7f3b44 2 | import threading 3 | import typing 4 | 5 | stack: list = None 6 | thread: threading.Thread = None 7 | _condition: threading.Condition = None 8 | 9 | 10 | def setup(): 11 | global stack, thread, _condition 12 | 13 | stack = [] 14 | _condition = threading.Condition() 15 | 16 | def run_loop(): 17 | while True: 18 | while stack: 19 | stack.pop()() 20 | with _condition: 21 | _condition.wait() 22 | 23 | thread = threading.Thread(target=run_loop, daemon=True) 24 | thread.start() 25 | 26 | 27 | def queue(fn: typing.Callable): 28 | stack.append(fn) 29 | with _condition: 30 | _condition.notify() 31 | 32 | 33 | # Example usage 34 | if __name__ == "__main__": 35 | import sync_thread # This script is designed as a module you import 36 | sync_thread.setup() 37 | 38 | def say_hello(): 39 | print("Hello world!") 40 | 41 | for _ in range(10): 42 | sync_thread.queue(say_hello) 43 | -------------------------------------------------------------------------------- /external/weakerset.py: -------------------------------------------------------------------------------- 1 | # Python's built-in weakref.WeakSet, but thread safe 2 | 3 | from _weakref import ref 4 | from types import GenericAlias 5 | import threading 6 | 7 | __all__ = ['WeakerSet'] 8 | 9 | 10 | class _IterationGuard: 11 | # This context manager registers itself in the current iterators of the 12 | # weak container, such as to delay all removals until the context manager 13 | # exits. 14 | 15 | def __init__(self, weakcontainer): 16 | # Don't create cycles 17 | self.weakcontainer = ref(weakcontainer) 18 | 19 | def __enter__(self): 20 | w = self.weakcontainer() 21 | if w is not None: 22 | w._iterating.add(self) 23 | return self 24 | 25 | def __exit__(self, e, t, b): 26 | w = self.weakcontainer() 27 | if w is not None: 28 | s = w._iterating 29 | s.remove(self) 30 | if not s: 31 | w._commit_removals() 32 | 33 | 34 | class WeakerSet: 35 | def __init__(self, data=None): 36 | self.lock = threading.RLock() 37 | self.data = set() 38 | def _remove(item, selfref=ref(self)): 39 | self = selfref() 40 | if self is not None: 41 | if self._iterating: 42 | self._pending_removals.append(item) 43 | else: 44 | self.data.discard(item) 45 | self._remove = _remove 46 | # A list of keys to be removed 47 | self._pending_removals = [] 48 | self._iterating = set() 49 | if data is not None: 50 | self.update(data) 51 | 52 | def _commit_removals(self): 53 | with self.lock: 54 | pop = self._pending_removals.pop 55 | discard = self.data.discard 56 | while True: 57 | try: 58 | item = pop() 59 | except IndexError: 60 | return 61 | discard(item) 62 | 63 | def __iter__(self): 64 | with self.lock: 65 | with _IterationGuard(self): 66 | for itemref in self.data: 67 | item = itemref() 68 | if item is not None: 69 | # Caveat: the iterator will keep a strong reference to 70 | # `item` until it is resumed or closed. 71 | yield item 72 | 73 | def __len__(self): 74 | with self.lock: 75 | return len(self.data) - len(self._pending_removals) 76 | 77 | def __contains__(self, item): 78 | with self.lock: 79 | try: 80 | wr = ref(item) 81 | except TypeError: 82 | return False 83 | return wr in self.data 84 | 85 | def __reduce__(self): 86 | with self.lock: 87 | return self.__class__, (list(self),), self.__getstate__() 88 | 89 | def add(self, item): 90 | with self.lock: 91 | if self._pending_removals: 92 | self._commit_removals() 93 | self.data.add(ref(item, self._remove)) 94 | 95 | def clear(self): 96 | with self.lock: 97 | if self._pending_removals: 98 | self._commit_removals() 99 | self.data.clear() 100 | 101 | def copy(self): 102 | with self.lock: 103 | return self.__class__(self) 104 | 105 | def pop(self): 106 | with self.lock: 107 | if self._pending_removals: 108 | self._commit_removals() 109 | while True: 110 | try: 111 | itemref = self.data.pop() 112 | except KeyError: 113 | raise KeyError('pop from empty WeakerSet') from None 114 | item = itemref() 115 | if item is not None: 116 | return item 117 | 118 | def remove(self, item): 119 | with self.lock: 120 | if self._pending_removals: 121 | self._commit_removals() 122 | self.data.remove(ref(item)) 123 | 124 | def discard(self, item): 125 | with self.lock: 126 | if self._pending_removals: 127 | self._commit_removals() 128 | self.data.discard(ref(item)) 129 | 130 | def update(self, other): 131 | with self.lock: 132 | if self._pending_removals: 133 | self._commit_removals() 134 | for element in other: 135 | self.add(element) 136 | 137 | def __ior__(self, other): 138 | with self.lock: 139 | self.update(other) 140 | return self 141 | 142 | def difference(self, other): 143 | with self.lock: 144 | newset = self.copy() 145 | newset.difference_update(other) 146 | return newset 147 | __sub__ = difference 148 | 149 | def difference_update(self, other): 150 | self.__isub__(other) 151 | def __isub__(self, other): 152 | with self.lock: 153 | if self._pending_removals: 154 | self._commit_removals() 155 | if self is other: 156 | self.data.clear() 157 | else: 158 | self.data.difference_update(ref(item) for item in other) 159 | return self 160 | 161 | def intersection(self, other): 162 | with self.lock: 163 | return self.__class__(item for item in other if item in self) 164 | __and__ = intersection 165 | 166 | def intersection_update(self, other): 167 | self.__iand__(other) 168 | def __iand__(self, other): 169 | with self.lock: 170 | if self._pending_removals: 171 | self._commit_removals() 172 | self.data.intersection_update(ref(item) for item in other) 173 | return self 174 | 175 | def issubset(self, other): 176 | with self.lock: 177 | return self.data.issubset(ref(item) for item in other) 178 | __le__ = issubset 179 | 180 | def __lt__(self, other): 181 | with self.lock: 182 | return self.data < set(map(ref, other)) 183 | 184 | def issuperset(self, other): 185 | with self.lock: 186 | return self.data.issuperset(ref(item) for item in other) 187 | __ge__ = issuperset 188 | 189 | def __gt__(self, other): 190 | with self.lock: 191 | return self.data > set(map(ref, other)) 192 | 193 | def __eq__(self, other): 194 | with self.lock: 195 | if not isinstance(other, self.__class__): 196 | return NotImplemented 197 | return self.data == set(map(ref, other)) 198 | 199 | def symmetric_difference(self, other): 200 | with self.lock: 201 | newset = self.copy() 202 | newset.symmetric_difference_update(other) 203 | return newset 204 | __xor__ = symmetric_difference 205 | 206 | def symmetric_difference_update(self, other): 207 | self.__ixor__(other) 208 | def __ixor__(self, other): 209 | with self.lock: 210 | if self._pending_removals: 211 | self._commit_removals() 212 | if self is other: 213 | self.data.clear() 214 | else: 215 | self.data.symmetric_difference_update(ref(item, self._remove) for item in other) 216 | return self 217 | 218 | def union(self, other): 219 | with self.lock: 220 | return self.__class__(e for s in (self, other) for e in s) 221 | __or__ = union 222 | 223 | def isdisjoint(self, other): 224 | with self.lock: 225 | return len(self.intersection(other)) == 0 226 | 227 | def __repr__(self): 228 | with self.lock: 229 | return repr(self.data) 230 | 231 | __class_getitem__ = classmethod(GenericAlias) 232 | -------------------------------------------------------------------------------- /external/ziparch.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/WillyJL/9e3e6fac4fdbdb3d40fccdb329fe32f1 2 | import os 3 | import pathlib 4 | import zipfile 5 | 6 | ZIP_COMPRESS_TYPE = zipfile.ZIP_DEFLATED 7 | 8 | ZIP_ARCH_EXTENSION = ".zip" 9 | 10 | 11 | def zip_sanitizer_filter(zipinfo: zipfile.ZipInfo): 12 | zipinfo.date_time = (1980, 1, 1, 0, 0, 0) # Minimum date 13 | if zipinfo.is_dir(): 14 | zipinfo.external_attr = 0o40775 << 16 # drwxrwxr-x 15 | zipinfo.external_attr |= 0x10 # MS-DOS directory flag 16 | else: 17 | zipinfo.external_attr = 0o664 << 16 # ?rw-rw-r-- 18 | zipinfo.create_system = 0 # Unix-like 19 | return zipinfo 20 | 21 | 22 | def compress_tree_ziparch( 23 | src_dir, 24 | output_name, 25 | filter=zip_sanitizer_filter, 26 | gz_level=9, 27 | ): 28 | top = pathlib.Path(src_dir) 29 | original_size = 0 30 | 31 | with zipfile.ZipFile( 32 | file=output_name, 33 | mode="w", 34 | compression=ZIP_COMPRESS_TYPE, 35 | compresslevel=gz_level, 36 | ) as ziparch: 37 | for cur, dirs, files in os.walk(top): 38 | cur = pathlib.Path(cur) 39 | dirs.sort() 40 | files.sort() 41 | 42 | if cur != top: 43 | zipinfo = zipfile.ZipInfo.from_file(cur, cur.relative_to(top)) 44 | zipinfo.compress_size = 0 45 | zipinfo.CRC = 0 46 | ziparch.mkdir(filter(zipinfo)) 47 | 48 | for file in files: 49 | path = cur / file 50 | original_size += top.stat().st_size 51 | zipinfo = zipfile.ZipInfo.from_file(path, path.relative_to(top)) 52 | ziparch.writestr( 53 | filter(zipinfo), 54 | path.read_bytes(), 55 | compress_type=ZIP_COMPRESS_TYPE, 56 | compresslevel=gz_level, 57 | ) 58 | 59 | return original_size, os.stat(output_name).st_size 60 | -------------------------------------------------------------------------------- /indexer-main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import contextlib 3 | import logging 4 | import os 5 | import pathlib 6 | import re 7 | 8 | import fastapi 9 | import uvicorn 10 | 11 | from indexer import ( 12 | cache, 13 | f95zone, 14 | threads, 15 | watcher, 16 | ) 17 | 18 | logger = logging.getLogger() 19 | 20 | 21 | @contextlib.asynccontextmanager 22 | async def lifespan(app: fastapi.FastAPI): 23 | async with ( 24 | cache.lifespan(), 25 | f95zone.lifespan(), 26 | watcher.lifespan(), 27 | ): 28 | yield 29 | 30 | 31 | app = fastapi.FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None) 32 | app.include_router(threads.router) 33 | 34 | 35 | def main() -> None: 36 | logger.setLevel(logging.INFO) 37 | log_handler = logging.StreamHandler() 38 | log_handler.setFormatter(_ColourFormatter()) 39 | logger.addHandler(log_handler) 40 | 41 | uvicorn.run( 42 | "indexer-main:app", 43 | host=os.environ.get("BIND_HOST", "127.0.0.1"), 44 | port=int(os.environ.get("BIND_HOST", 8069)), 45 | workers=1, 46 | log_config=None, 47 | log_level=logging.INFO, 48 | access_log=False, 49 | env_file="indexer.env", 50 | ) 51 | 52 | 53 | # https://github.com/Rapptz/discord.py/blob/master/discord/utils.py 54 | class _ColourFormatter(logging.Formatter): 55 | LEVEL_COLOURS = [ 56 | (logging.DEBUG, "\x1b[30;1m"), 57 | (logging.INFO, "\x1b[34;1m"), 58 | (logging.WARNING, "\x1b[33;1m"), 59 | (logging.ERROR, "\x1b[31m"), 60 | (logging.CRITICAL, "\x1b[41m"), 61 | ] 62 | FORMATS = { 63 | level: logging.Formatter( 64 | f"\x1b[30;1m%(asctime)s\x1b[0m {colour}%(levelname)-8s\x1b[0m \x1b[35m%(name)s\x1b[0m %(message)s", 65 | "%Y-%m-%d %H:%M:%S", 66 | ) 67 | for level, colour in LEVEL_COLOURS 68 | } 69 | 70 | def format(self, record): 71 | formatter = self.FORMATS.get(record.levelno) 72 | if formatter is None: 73 | formatter = self.FORMATS[logging.DEBUG] 74 | if record.exc_info: 75 | text = formatter.formatException(record.exc_info) 76 | record.exc_text = f"\x1b[31m{text}\x1b[0m" 77 | output = formatter.format(record) 78 | record.exc_text = None 79 | return output 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /indexer.env-example: -------------------------------------------------------------------------------- 1 | COOKIE_XF_USER="" 2 | -------------------------------------------------------------------------------- /indexer/cache.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import datetime as dt 4 | import logging 5 | import time 6 | 7 | import redis.asyncio as aredis 8 | 9 | from common import ( 10 | meta, 11 | parser, 12 | ) 13 | from external import error 14 | from indexer import ( 15 | f95zone, 16 | scraper, 17 | ) 18 | 19 | CACHE_TTL = dt.timedelta(days=7).total_seconds() 20 | SHORT_TTL = dt.timedelta(days=2).total_seconds() 21 | LAST_CHANGE_ELIGIBLE_FIELDS = ( 22 | "name", 23 | "version", 24 | "developer", 25 | "type", 26 | "status", 27 | "last_updated", 28 | "score", 29 | "votes", 30 | "description", 31 | "changelog", 32 | "tags", 33 | "unknown_tags", 34 | "image_url", 35 | "previews_urls", 36 | "downloads", 37 | "reviews_total", 38 | "reviews", 39 | "INDEX_ERROR", 40 | ) 41 | 42 | logger = logging.getLogger(__name__) 43 | redis: aredis.Redis = None 44 | locks_lock = asyncio.Lock() 45 | locks: dict[asyncio.Lock] = {} 46 | 47 | LAST_CACHED = "LAST_CACHED" 48 | EXPIRE_TIME = "EXPIRE_TIME" 49 | INDEX_ERROR = "INDEX_ERROR" 50 | INTERNAL_KEYWORDS = ( 51 | CACHED_WITH := "CACHED_WITH", 52 | LAST_CHANGE := "LAST_CHANGE", 53 | HASHED_META := "HASHED_META", 54 | ) 55 | NAME_FORMAT = "thread:{id}" 56 | 57 | 58 | @contextlib.asynccontextmanager 59 | async def lifespan(): 60 | global redis 61 | redis = aredis.Redis(decode_responses=True) 62 | await redis.ping() 63 | 64 | try: 65 | yield 66 | finally: 67 | 68 | await redis.aclose() 69 | redis = None 70 | 71 | 72 | # https://stackoverflow.com/a/67057328 73 | @contextlib.asynccontextmanager 74 | async def lock(id: int): 75 | async with locks_lock: 76 | if not locks.get(id): 77 | locks[id] = asyncio.Lock() 78 | async with locks[id]: 79 | yield 80 | async with locks_lock: 81 | if (lock := locks.get(id)) and not lock.locked() and not lock._waiters: 82 | del locks[id] 83 | 84 | 85 | async def last_change(id: int) -> int: 86 | assert isinstance(id, int) 87 | name = NAME_FORMAT.format(id=id) 88 | logger.debug(f"Last change {name}") 89 | 90 | await _maybe_update_thread_cache(id, name) 91 | 92 | last_change = await redis.hget(name, LAST_CHANGE) or 0 93 | return int(last_change) 94 | 95 | 96 | async def get_thread(id: int) -> dict[str, str]: 97 | assert isinstance(id, int) 98 | name = NAME_FORMAT.format(id=id) 99 | logger.debug(f"Get {name}") 100 | 101 | await _maybe_update_thread_cache(id, name) 102 | 103 | thread = await redis.hgetall(name) 104 | 105 | # Remove internal fields from response 106 | for key in INTERNAL_KEYWORDS: 107 | if key in thread: 108 | del thread[key] 109 | return thread 110 | 111 | 112 | async def _is_thread_cache_outdated(id: int, name: str) -> bool: 113 | last_cached, expire_time = await redis.hmget(name, (LAST_CACHED, EXPIRE_TIME)) 114 | if last_cached and not expire_time: 115 | expire_time = int(last_cached) + CACHE_TTL 116 | # Never cached or cache expired 117 | return not last_cached or time.time() >= int(expire_time) 118 | 119 | 120 | async def _maybe_update_thread_cache(id: int, name: str) -> None: 121 | # Check without lock first to avoid bottlenecks 122 | if not await _is_thread_cache_outdated(id, name): 123 | return 124 | 125 | # If it might be outdated, check with lock to avoid multiple updates 126 | async with lock(id): 127 | if await _is_thread_cache_outdated(id, name): 128 | await _update_thread_cache(id, name) 129 | 130 | 131 | async def _update_thread_cache(id: int, name: str) -> None: 132 | logger.info(f"Update cached {name}") 133 | 134 | try: 135 | result = await scraper.thread(id) 136 | except Exception: 137 | logger.error(f"Exception caching {name}: {error.text()}\n{error.traceback()}") 138 | result = f95zone.ERROR_INTERNAL_ERROR 139 | old_fields = await redis.hgetall(name) 140 | now = time.time() 141 | 142 | if isinstance(result, f95zone.IndexerError): 143 | # Something went wrong, keep cache and retry sooner/later 144 | new_fields = { 145 | INDEX_ERROR: result.error_flag, 146 | EXPIRE_TIME: int(now + result.retry_delay), 147 | } 148 | # Consider new error as a change 149 | if old_fields.get(INDEX_ERROR) != new_fields.get(INDEX_ERROR): 150 | new_fields[LAST_CHANGE] = int(now) 151 | else: 152 | # F95zone responded, cache new thread data 153 | new_fields = { 154 | **result, 155 | INDEX_ERROR: "", 156 | EXPIRE_TIME: int(now + CACHE_TTL), 157 | } 158 | # Recache more often if using thread_version 159 | if "thread_version" in new_fields: 160 | del new_fields["thread_version"] 161 | new_fields[EXPIRE_TIME] = int(now + SHORT_TTL) 162 | # Special treatment for the almighty sacred last updated date 163 | if ( 164 | new_fields.get("version") 165 | and old_fields.get("version") 166 | and (new_fields["version"] != old_fields["version"]) 167 | ): 168 | # Version changed, use today as last updated date 169 | new_fields["last_updated"] = str(parser.datestamp(now)) 170 | elif old_fields.get("last_updated"): 171 | # Was previously cached and version didn't change, keep previous date 172 | new_fields["last_updated"] = old_fields["last_updated"] 173 | else: 174 | # Not previously cached, use date from thread / latest updates 175 | pass 176 | # Track last time that some meaningful data changed to tell clients to full check it 177 | if any( 178 | new_fields.get(key) != old_fields.get(key) 179 | for key in LAST_CHANGE_ELIGIBLE_FIELDS 180 | ): 181 | new_fields[LAST_CHANGE] = int(now) 182 | logger.info(f"Data for {name} changed") 183 | 184 | new_fields[LAST_CACHED] = int(now) 185 | new_fields[CACHED_WITH] = meta.version 186 | if LAST_CHANGE not in old_fields and LAST_CHANGE not in new_fields: 187 | new_fields[LAST_CHANGE] = int(now) 188 | await redis.hmset(name, new_fields) 189 | -------------------------------------------------------------------------------- /indexer/f95zone.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import dataclasses 4 | import datetime as dt 5 | import logging 6 | import os 7 | import sys 8 | 9 | import aiohttp 10 | import aiolimiter 11 | 12 | from common import meta 13 | 14 | RATELIMIT = aiolimiter.AsyncLimiter(max_rate=1, time_period=0.5) 15 | TIMEOUT = aiohttp.ClientTimeout(total=30, connect=30, sock_read=30, sock_connect=30) 16 | LOGIN_ERROR_MESSAGES = ( 17 | b'Log in or register now.', 18 | b"