diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 20a15a0..c9f0392 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,105 +7,57 @@ on: branches: - master -concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: true - -env: - IMAGE: zedeus/nitter - jobs: tests: uses: ./.github/workflows/run-tests.yml secrets: inherit - - build: + build-docker-amd64: needs: [tests] - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux/amd64 - - runner: ubuntu-24.04-arm - platform: linux/arm64 - runs-on: ${{ matrix.runner }} + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - - name: Prepare platform name - run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - env: - platform: ${{ matrix.platform }} - - - uses: actions/checkout@v6 - + - uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + id: buildx + uses: docker/setup-buildx-action@v2 with: version: latest - - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push by digest - id: build - uses: docker/build-push-action@v6 + - name: Build and push AMD64 Docker image + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile - platforms: ${{ matrix.platform }} - outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - provenance: false - sbom: false - - - name: Export digest - run: | - mkdir -p "${{ runner.temp }}/digests" - digest="${{ steps.build.outputs.digest }}" - touch "${{ runner.temp }}/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: digests-${{ env.PLATFORM_PAIR }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 - - # Combine the per-arch digests into one multi-arch manifest so that - # `docker pull zedeus/nitter:latest` serves the right image on any CPU. - merge: - needs: [build] - runs-on: ubuntu-24.04 + platforms: linux/amd64 + push: true + tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} + build-docker-arm64: + needs: [tests] + runs-on: buildjet-2vcpu-ubuntu-2204-arm steps: - - name: Download digests - uses: actions/download-artifact@v4 + - uses: actions/checkout@v3 with: - path: ${{ runner.temp }}/digests - pattern: digests-* - merge-multiple: true - + fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + id: buildx + uses: docker/setup-buildx-action@v2 with: version: latest - - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Create manifest list and push - working-directory: ${{ runner.temp }}/digests - run: | - docker buildx imagetools create \ - -t ${{ env.IMAGE }}:latest \ - -t ${{ env.IMAGE }}:latest-arm64 \ - -t ${{ env.IMAGE }}:${{ github.sha }} \ - $(printf '${{ env.IMAGE }}@sha256:%s ' *) - - - name: Inspect image - run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }} + - name: Build and push ARM64 Docker image + uses: docker/build-push-action@v3 + with: + context: . + file: ./Dockerfile.arm64 + platforms: linux/arm64 + push: true + tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 33dcba5..f4639a4 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,24 +20,24 @@ defaults: jobs: build-test: name: Build and test - runs-on: ubuntu-24.04 + runs-on: buildjet-2vcpu-ubuntu-2204 strategy: matrix: nim: ["2.0.x", "2.2.x", "devel"] steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Cache Nimble Dependencies id: cache-nimble - uses: actions/cache@v5 + uses: buildjet/cache@v4 with: - path: | - ~/.nimble/pkgcache - ~/.nimble/packages_official.json - key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }} + path: ~/.nimble + key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} restore-keys: | - ${{ matrix.nim }}-nimble-v6- + ${{ matrix.nim }}-nimble-v2- - name: Setup Nim uses: jiro4989/setup-nim-action@v2 @@ -47,106 +47,62 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build Project - run: nimble build -Y - - - name: Upload 2.2.x build artifact - if: matrix.nim == '2.2.x' - uses: actions/upload-artifact@v6 - with: - name: nitter-linux-nim-2.2.x-${{ github.sha }} - path: | - ./nitter - if-no-files-found: error + run: nimble build -d:release -Y integration-test: needs: [build-test] name: Integration test - runs-on: ubuntu-24.04 - timeout-minutes: 30 - - services: - redis: - image: redis:7 - ports: - - 6379:6379 - + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - - name: Install runtime deps - run: | - sudo apt-get install -y --no-install-recommends libsass-dev libpcre3 - - name: Checkout code - uses: actions/checkout@v6 - - - name: Cache pipx (poetry) - uses: actions/cache@v5 + uses: actions/checkout@v4 with: - path: | - ~/.local/pipx - ~/.local/bin - key: pipx-poetry-${{ runner.os }} - - - name: Install poetry - env: - PIPX_HOME: ~/.local/pipx - PIPX_BIN_DIR: ~/.local/bin - run: command -v poetry >/dev/null 2>&1 || pipx install poetry - - - name: Setup Python (3.14) with Poetry cache - uses: actions/setup-python@v6 - with: - python-version: "3.14" - cache: poetry - cache-dependency-path: tests/poetry.lock - - - name: Install Python deps - working-directory: tests - run: poetry sync + fetch-depth: 0 - name: Cache Nimble Dependencies - uses: actions/cache@v5 + id: cache-nimble + uses: buildjet/cache@v4 with: - path: | - ~/.nimble/pkgcache - ~/.nimble/packages_official.json - key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }} + path: ~/.nimble + key: devel-nimble-v2-${{ hashFiles('*.nimble') }} restore-keys: | - 2.2.x-nimble-v6- + devel-nimble-v2- + + - name: Setup Python (3.10) with pip cache + uses: buildjet/setup-python@v4 + with: + python-version: "3.10" + cache: pip - name: Setup Nim uses: jiro4989/setup-nim-action@v2 with: - nim-version: 2.2.x + nim-version: devel use-nightlies: true repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Nimble dependencies - run: nimble install -y --depsOnly + - name: Build Project + run: nimble build -d:release -Y - - name: Download 2.2.x build artifact - uses: actions/download-artifact@v4 - with: - name: nitter-linux-nim-2.2.x-${{ github.sha }} - path: . + - name: Install SeleniumBase and Chromedriver + run: | + pip install seleniumbase + seleniumbase install chromedriver - - name: Make nitter binary executable - run: chmod +x ./nitter + - name: Start Redis Service + uses: supercharge/redis-github-action@1.5.0 - name: Prepare Nitter Environment run: | + sudo apt-get update && sudo apt-get install -y libsass-dev cp nitter.example.conf nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf - sed -i 's/maxRetries = 1/maxRetries = 3/g' nitter.conf - sed -i 's/hostname = "nitter.net"/hostname = "localhost:8080"/g' nitter.conf - - nim r tools/rendermd.nim - nim r tools/gencss.nim - + nimble md + nimble scss echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - name: Run Tests run: | ./nitter & - cd tests - poetry run pytest -n2 --rs . + pytest -n1 tests diff --git a/.gitignore b/.gitignore index 2e52163..dbd2f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,3 @@ nitter.conf guest_accounts.json* sessions.json* dump.rdb -*.bak -/tools/*.json* -nimbledeps/ -nimble.paths -nimble.develop diff --git a/Dockerfile b/Dockerfile index 251b63a..ab442ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nimlang/nim:2.2.6-alpine-regular as nim +FROM nimlang/nim:2.2.0-alpine-regular as nim LABEL maintainer="setenforce@protonmail.com" RUN apk --no-cache add libsass-dev pcre @@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ FROM alpine:latest WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates openssl +RUN apk --no-cache add pcre ca-certificates COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/public ./public diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 0000000..46352c7 --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,25 @@ +FROM alpine:3.20.6 as nim +LABEL maintainer="setenforce@protonmail.com" + +RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble + +WORKDIR /src/nitter + +COPY nitter.nimble . +RUN nimble install -y --depsOnly + +COPY . . +RUN nimble build -d:danger -d:lto -d:strip --mm:refc \ + && nimble scss \ + && nimble md + +FROM alpine:3.20.6 +WORKDIR /src/ +RUN apk --no-cache add pcre ca-certificates openssl +COPY --from=nim /src/nitter/nitter ./ +COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf +COPY --from=nim /src/nitter/public ./public +EXPOSE 8080 +RUN adduser -h /src/ -D -s /bin/sh nitter +USER nitter +CMD ./nitter diff --git a/README.md b/README.md index 86ebd47..05c2be4 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,18 @@ # Nitter +[![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml) +[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml) +[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license) + > [!NOTE] -> On 24 August 2026 cease and desist letters were sent by X Corp. demanding a permanent takedown of Nitter instances and the project's repository. +> Running a Nitter instance now requires real accounts, since Twitter removed the previous methods. \ +> This does not affect users. \ +> For instructions on how to obtain session tokens, see [Creating session tokens](https://github.com/zedeus/nitter/wiki/Creating-session-tokens). A free and open source alternative Twitter front-end focused on privacy and performance. \ Inspired by the [Invidious](https://github.com/iv-org/invidious) project. -## Donations - -**Liberapay**: https://liberapay.com/zedeus
-**Patreon**: https://patreon.com/nitter
-**Ko-fi**: https://ko-fi.com/zedeus
-**BTC**: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
-**ETH**: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
-**XMR**: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
-**SOL**: FF5bheiD5AqPEdc3eyjymJ8AoMRF1hS78Ht6FiSZZF1t
-**$Nitter**: 4fSxCKc91ELQYVdv3tmHW8R15KoALPwEngyoQe1Xpump
-**ZEC**: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt - -## Features - - No JavaScript or ads - All requests go through the backend, client never talks to Twitter - Prevents Twitter from tracking your IP or JavaScript fingerprint @@ -31,6 +23,17 @@ Inspired by the [Invidious](https://github.com/iv-org/invidious) project. - Mobile support (responsive design) - AGPLv3 licensed, no proprietary instances permitted +
+Donations +Liberapay: https://liberapay.com/zedeus
+Patreon: https://patreon.com/nitter
+BTC: bc1qpqpzjkcpgluhzf7x9yqe7jfe8gpfm5v08mdr55
+ETH: 0x24a0DB59A923B588c7A5EBd0dBDFDD1bCe9c4460
+XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
+SOL: ANsyGNXFo6osuFwr1YnUqif2RdoYRhc27WdyQNmmETSW
+ZEC: u1vndfqtzyy6qkzhkapxelel7ams38wmfeccu3fdpy2wkuc4erxyjm8ncjhnyg747x6t0kf0faqhh2hxyplgaum08d2wnj4n7cyu9s6zhxkqw2aef4hgd4s6vh5hpqvfken98rg80kgtgn64ff70djy7s8f839z00hwhuzlcggvefhdlyszkvwy3c7yw623vw3rvar6q6evd3xcvveypt +
+ ## Roadmap - Embeds @@ -101,9 +104,9 @@ along with the scss and md files. # su nitter $ git clone https://github.com/zedeus/nitter $ cd nitter -$ nimble -l build -d:danger --mm:refc -$ nimble -l scss -$ nimble -l md +$ nimble build -d:danger --mm:refc +$ nimble scss +$ nimble md $ cp nitter.example.conf nitter.conf ``` @@ -120,23 +123,12 @@ performance reasons. Page for the Docker image: https://hub.docker.com/r/zedeus/nitter -#### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`. +#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). To run Nitter with Docker, you'll need to install and run Redis separately before you can run the container. See below for how to also run Redis using Docker. -First create your config file. The Docker commands mount it into the container, -so it has to exist on the host beforehand. If you've cloned the repo: - -```bash -cp nitter.example.conf nitter.conf -``` - -If you're using the prebuilt image without a local clone, download -[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf) -and save it as `nitter.conf` instead. - To build and run Nitter in Docker: ```bash @@ -144,6 +136,8 @@ docker build -t nitter:latest . docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest ``` +Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64). + A prebuilt Docker image is provided as well: ```bash @@ -157,11 +151,8 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run docker-compose up -d ``` -Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for -docker-compose) from the directory you run them in. If a mounted file doesn't -exist, Docker silently creates a directory in its place and the container fails -with `not a directory: Are you trying to mount a directory onto a file`. Remove -that directory and create the file as shown above. +Note the Docker commands expect a `nitter.conf` file in the directory you run +them. ### systemd @@ -206,5 +197,3 @@ lines). If you're running the Docker image, you can do this: Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). You can email me at zedeus@pm.me if you wish to contact me personally. - -For legal inquiries, contact legal@poast.org diff --git a/config.nims b/config.nims index 3ee4842..4a7af27 100644 --- a/config.nims +++ b/config.nims @@ -11,7 +11,3 @@ warning("HoleEnumConv", off) hint("XDeclaredButNotUsed", off) hint("XCannotRaiseY", off) hint("User", off) -# begin Nimble config (version 2) -when withDir(thisDir(), system.fileExists("nimble.paths")): - include "nimble.paths" -# end Nimble config diff --git a/compose.yml b/docker-compose.yml similarity index 98% rename from compose.yml rename to docker-compose.yml index 72d5a96..3d75751 100644 --- a/compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: "3" + services: nitter: diff --git a/nitter.example.conf b/nitter.example.conf index 4e040f8..bddb9a4 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,42 +1,31 @@ [Server] -hostname = "nitter.net" # for generating links, change this to your own domain/ip +hostname = "nitter.net" # for generating links, change this to your own domain/ip title = "nitter" address = "0.0.0.0" port = 8080 -https = false # disable to enable cookies when not using https +https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" [Cache] -listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) -rssMinutes = 10 # how long to cache rss queries -redisHost = "localhost" # Change to "nitter-redis" if using docker-compose +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # minimum open connections in pool +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 # new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second [Config] -hmacKey = "secretkey" # CHANGE THIS to a unique random value (e.g. `openssl rand -hex 32`); signs media urls -base64Media = false # use base64 encoding for proxied media urls -enableRSS = true # master switch, set to false to disable all RSS feeds -enableRSSUserTweets = true # /@user/rss -enableRSSUserReplies = true # /@user/with_replies/rss -enableRSSUserMedia = true # /@user/media/rss -enableRSSUserArticles = true # /@user/articles/rss -enableRSSSearch = true # /search/rss and /@user/search/rss -enableRSSList = true # list RSS feeds -enableDebug = false # enable request logs and debug endpoints (/.sessions) -proxy = "" # http/https url, SOCKS proxies are not supported +hmacKey = "secretkey" # random key for cryptographic signing of video urls +base64Media = false # use base64 encoding for proxied media urls +enableRSS = true # set this to false to disable RSS feeds +enableDebug = false # enable request logs and debug endpoints (/.sessions) +proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" -apiProxy = "" # nitter-proxy host, e.g. localhost:7000 -disableTid = false # enable this if cookie-based auth is failing -maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions -maxRetries = 1 # max number of retries on rate limit errors -retryDelayMs = 150 # delay in ms between retries # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/nitter.nimble b/nitter.nimble index b36f498..7ff8196 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -11,23 +11,24 @@ bin = @["nitter"] # Dependencies requires "nim >= 2.0.0" -requires "jester == 0.6.0" -requires "karax == 1.5.0" -requires "sass == 0.2.0" -requires "nimcrypto == 0.7.3" -requires "markdown == 0.8.8" +requires "jester#baca3f" +requires "karax#5cf360c" +requires "sass#7dfdd03" +requires "nimcrypto#a079df9" +requires "markdown#158efe3" requires "packedjson#9e6fbb6" -requires "supersnappy == 2.1.4" -requires "redpool == 0.2.2" -requires "zippy == 0.10.19" -requires "flatty == 0.4.0" -requires "jsony == 1.1.6" -requires "oauth == 0.11" +requires "supersnappy#6c94198" +requires "redpool#8b7c1db" +requires "https://github.com/zedeus/redis#d0a0e6f" +requires "zippy#ca5989a" +requires "flatty#e668085" +requires "jsony#1de1f08" +requires "oauth#b8c163b" # Tasks task scss, "Generate css": - exec "nim r --hint[Processing]:off tools/gencss" + exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" task md, "Render md": - exec "nim r --hint[Processing]:off tools/rendermd" + exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" diff --git a/public/css/fontello.css b/public/css/fontello.css index 8f9abad..2453575 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,153 +1,53 @@ @font-face { - font-family: "fontello"; - src: url("/fonts/fontello.eot?59696369"); - src: - url("/fonts/fontello.eot?59696369#iefix") format("embedded-opentype"), - url("/fonts/fontello.woff2?59696369") format("woff2"), - url("/fonts/fontello.woff?59696369") format("woff"), - url("/fonts/fontello.ttf?59696369") format("truetype"), - url("/fonts/fontello.svg?59696369#fontello") format("svg"); + font-family: 'fontello'; + src: url('/fonts/fontello.eot?61663884'); + src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), + url('/fonts/fontello.woff2?61663884') format('woff2'), + url('/fonts/fontello.woff?61663884') format('woff'), + url('/fonts/fontello.ttf?61663884') format('truetype'), + url('/fonts/fontello.svg?61663884#fontello') format('svg'); font-weight: normal; font-style: normal; } - -[class^="icon-"]:before, -[class*=" icon-"]:before { +[class^="icon-"]:before, [class*=" icon-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; - margin-right: 0.2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.icon-group:before { - content: "\e0c3"; -} - -/* '' */ -.icon-views:before { - content: "\e800"; -} - -/* '' */ -.icon-heart:before { - content: "\e801"; -} - -/* '' */ -.icon-quote:before { - content: "\e802"; -} - -/* '' */ -.icon-comment:before { - content: "\e803"; -} - -/* '' */ -.icon-play:before { - content: "\e805"; -} - -/* '' */ -.icon-link:before { - content: "\e806"; -} - -/* '' */ -.icon-calendar:before { - content: "\e807"; -} - -/* '' */ -.icon-location:before { - content: "\e808"; -} - -/* '' */ -.icon-picture:before { - content: "\e809"; -} - -/* '' */ -.icon-lock:before { - content: "\e80a"; -} - -/* '' */ -.icon-down:before { - content: "\e80b"; -} - -/* '' */ -.icon-retweet:before { - content: "\e80c"; -} - -/* '' */ -.icon-search:before { - content: "\e80d"; -} - -/* '' */ -.icon-pin:before { - content: "\e80e"; -} - -/* '' */ -.icon-cog:before { - content: "\e80f"; -} - -/* '' */ -.icon-rss:before { - content: "\e810"; -} - -/* '' */ -.icon-ok:before { - content: "\e811"; -} - -/* '' */ -.icon-attention-circled:before { - content: "\e812"; -} - -/* '' */ -.icon-download-alt:before { - content: "\e813"; -} - -/* '' */ -.icon-circle:before { - content: "\f111"; -} - -/* '' */ -.icon-info:before { - content: "\f128"; -} - -/* '' */ -.icon-bird:before { - content: "\f309"; -} - -/* '' */ +.icon-views:before { content: '\e800'; } /* '' */ +.icon-heart:before { content: '\e801'; } /* '' */ +.icon-quote:before { content: '\e802'; } /* '' */ +.icon-comment:before { content: '\e803'; } /* '' */ +.icon-ok:before { content: '\e804'; } /* '' */ +.icon-play:before { content: '\e805'; } /* '' */ +.icon-link:before { content: '\e806'; } /* '' */ +.icon-calendar:before { content: '\e807'; } /* '' */ +.icon-location:before { content: '\e808'; } /* '' */ +.icon-picture:before { content: '\e809'; } /* '' */ +.icon-lock:before { content: '\e80a'; } /* '' */ +.icon-down:before { content: '\e80b'; } /* '' */ +.icon-retweet:before { content: '\e80c'; } /* '' */ +.icon-search:before { content: '\e80d'; } /* '' */ +.icon-pin:before { content: '\e80e'; } /* '' */ +.icon-cog:before { content: '\e80f'; } /* '' */ +.icon-rss:before { content: '\e810'; } /* '' */ +.icon-info:before { content: '\f128'; } /* '' */ +.icon-bird:before { content: '\f309'; } /* '' */ diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index a5b8b1c..2b2982a 100644 Binary files a/public/fonts/fontello.eot and b/public/fonts/fontello.eot differ diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 5dc65a8..2a64343 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,13 +1,11 @@ -Copyright (C) 2026 by original authors @ fontello.com +Copyright (C) 2025 by original authors @ fontello.com - - @@ -16,6 +14,8 @@ + + @@ -40,14 +40,6 @@ - - - - - - - - diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index a2b972e..ef775f8 100644 Binary files a/public/fonts/fontello.ttf and b/public/fonts/fontello.ttf differ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index 65508ca..63c3c23 100644 Binary files a/public/fonts/fontello.woff and b/public/fonts/fontello.woff differ diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index a8d96da..b7541f0 100644 Binary files a/public/fonts/fontello.woff2 and b/public/fonts/fontello.woff2 differ diff --git a/public/js/embedResize.js b/public/js/embedResize.js deleted file mode 100644 index 3fb05a0..0000000 --- a/public/js/embedResize.js +++ /dev/null @@ -1,34 +0,0 @@ -(function () { - var embed = document.querySelector(".embed-wrapper, .embed-video"); - if (!embed) return; - - var video = embed.querySelector("video"); - if (video) { - video.onplay = function () { - embed.classList.add("video-playing"); - }; - video.onpause = video.onended = function () { - embed.classList.remove("video-playing"); - }; - } - - var lastHeight = 0; - - function sendHeight() { - var h = embed.offsetHeight; - if (h !== lastHeight && h > 0) { - lastHeight = h; - window.parent.postMessage(["resizeIframe", { h: h }], "*"); - } - } - - // MessageChannel height request (used by oEmbed) - window.addEventListener("message", function (e) { - if (e.source === window.parent && e.ports && e.ports[0]) { - e.ports[0].postMessage(embed.offsetHeight); - } - }); - - window.addEventListener("load", sendHeight); - new ResizeObserver(sendHeight).observe(embed); -})(); diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 5970011..5cd46a6 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -1,30 +1,25 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only -function playMedia(overlay, tagName) { - const media = overlay.parentElement.querySelector(tagName); - const url = media.getAttribute("data-url"); - const startTime = parseFloat(media.getAttribute("data-start") || "0"); - media.setAttribute("controls", ""); +function playVideo(overlay) { + const video = overlay.parentElement.querySelector('video'); + const url = video.getAttribute("data-url"); + video.setAttribute("controls", ""); overlay.style.display = "none"; if (Hls.isSupported()) { var hls = new Hls({autoStartLoad: false}); hls.loadSource(url); - hls.attachMedia(media); + hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; - hls.startLoad(startTime); - media.play(); + hls.startLoad(); + video.play(); }); - } else if (media.canPlayType('application/vnd.apple.mpegurl')) { - media.src = url; - media.addEventListener('canplay', function() { - if (startTime > 0) media.currentTime = startTime; - media.play(); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + video.addEventListener('canplay', function() { + video.play(); }); } } - -function playVideo(overlay) { playMedia(overlay, 'video'); } -function playAudio(overlay) { playMedia(overlay, 'audio'); } // @license-end diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index f79912f..be27e0c 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,225 +1,77 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only - function insertBeforeLast(node, elem) { - node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); + node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } function getLoadMore(doc) { - return doc.querySelector(".show-more:not(.timeline-item)"); + return doc.querySelector(".show-more:not(.timeline-item)"); } -function getHrefs(selector) { - return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href"))); +function isDuplicate(item, itemClass) { + const tweet = item.querySelector(".tweet-link"); + if (tweet == null) return false; + const href = tweet.getAttribute("href"); + return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; } -function getTweetId(item) { - const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); - return m ? m[1] : ""; -} +window.onload = function () { + const url = window.location.pathname; + const isTweet = url.indexOf("/status/") !== -1; + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; -function isDuplicate(item, hrefs) { - return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); -} + var html = document.querySelector("html"); + var container = document.querySelector(containerClass); + var loading = false; -const GAP = 10; + function handleScroll(failed) { + if (loading) return; -class Masonry { - constructor(container) { - this.container = container; - const colSizes = { - small: w => Math.max(130, w * 0.11), - medium: w => Math.max(190, Math.min(350, w * 0.22)), - large: w => Math.max(350, Math.min(480, w * 0.22)), - }; - const size = container.dataset.colSize || "medium"; - this._targetWidth = colSizes[size] || colSizes.medium; - this.colHeights = []; - this.colCounts = []; - this.colCount = 0; - this._lastWidth = 0; - this._colWidthCache = 0; - this._items = []; - this._revealTimer = null; - this.container.classList.add("masonry-active"); + if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { + loading = true; + var loadMore = getLoadMore(document); + if (loadMore == null) return; - let resizeTimer; - window.addEventListener("resize", () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => this._rebuild(), 50); - }); + loadMore.children[0].text = "Loading..."; - // Re-sync positions whenever images finish loading and items grow taller. - // Must be set up before _rebuild() so initial items get observed on first pass. - let syncTimer; - this._observer = window.ResizeObserver ? new ResizeObserver(() => { - clearTimeout(syncTimer); - syncTimer = setTimeout(() => this.syncHeights(), 100); - }) : null; + var url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); - this._rebuild(); - } + fetch(url.toString()).then(function (response) { + if (response.status === 404) throw "error"; - // Reveal all items and gallery siblings (show-more, top-ref). Idempotent. - _revealAll() { - clearTimeout(this._revealTimer); - for (const item of this._items) item.classList.add("masonry-visible"); - for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer")) - el.classList.add("masonry-visible"); - } + return response.text(); + }).then(function (html) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, "text/html"); + loadMore.remove(); - // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images. - _pickCol() { - return this.colHeights.reduce((min, h, i) => { - const m = this.colHeights[min]; - return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min; - }, 0); - } + for (var item of doc.querySelectorAll(itemClass)) { + if (item.className == "timeline-item show-more") continue; + if (isDuplicate(item, itemClass)) continue; + if (isTweet) container.appendChild(item); + else insertBeforeLast(container, item); + } - // Position items using current column state. Updates colHeights, colCounts, container height. - _position(items, heights, colWidth) { - for (let i = 0; i < items.length; i++) { - const col = this._pickCol(); - items[i].style.left = `${col * (colWidth + GAP)}px`; - items[i].style.top = `${this.colHeights[col]}px`; - this.colHeights[col] += heights[i] + GAP; - this.colCounts[col]++; - } - this.container.style.height = `${Math.max(0, ...this.colHeights)}px`; - } + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore == null) return; + if (isTweet) container.appendChild(newLoadMore); + else insertBeforeLast(container, newLoadMore); + }).catch(function (err) { + console.warn("Something went wrong.", err); + if (failed > 3) { + loadMore.children[0].text = "Error"; + return; + } - // Full reset and re-place all items. - _place(items, heights, n, colWidth) { - this.colHeights = new Array(n).fill(0); - this.colCounts = new Array(n).fill(0); - this.colCount = n; - this._position(items, heights, colWidth); - } - - _rebuild() { - const w = this.container.clientWidth; - const n = Math.max(1, Math.floor(w / this._targetWidth(w))); - if (n === this.colCount && w === this._lastWidth) return; - - const isFirst = this.colCount === 0; - - if (isFirst) { - this._items = [...this.container.querySelectorAll(".timeline-item")]; - } - - // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings). - this._items.sort((a, b) => { - const idA = getTweetId(a), idB = getTweetId(b); - if (idA.length !== idB.length) return idB.length - idA.length; - return idB < idA ? -1 : idB > idA ? 1 : 0; - }); - - // Pre-set widths BEFORE reading heights so measurements reflect the new column width. - const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n); - for (const item of this._items) item.style.width = `${colWidth}px`; - - this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth); - this._lastWidth = w; - - if (isFirst) { - if (this._observer) this._items.forEach(item => this._observer.observe(item)); - // Reveal immediately if all images are cached, else wait for syncHeights. - const hasUnloaded = this._items.some(item => - [...item.querySelectorAll("img")].some(img => !img.complete)); - if (hasUnloaded) { - this._revealTimer = setTimeout(() => this._revealAll(), 1000); - } else { - this._revealAll(); - } - } - } - - // Re-read actual heights and re-place all items. Fixes drift after images load. - syncHeights() { - this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache); - this._revealAll(); - } - - // Batch-add items in three phases to avoid O(N) reflows: - // 1. writes: set widths, append all — no reads, no reflows - // 2. one read: batch offsetHeight - // 3. writes: assign columns, set left/top - addAll(newItems) { - if (!newItems.length) return; - const colWidth = this._colWidthCache; - - for (const item of newItems) { - item.style.width = `${colWidth}px`; - this.container.appendChild(item); - } - - this._position(newItems, newItems.map(item => item.offsetHeight), colWidth); - this._items.push(...newItems); - - if (this._observer) newItems.forEach(item => this._observer.observe(item)); - } -} - -document.addEventListener("DOMContentLoaded", function () { - const isTweet = location.pathname.includes("/status/"); - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; - const html = document.documentElement; - const container = document.querySelector(containerClass); - const masonryEl = container?.querySelector(".gallery-masonry"); - const masonry = masonryEl ? new Masonry(masonryEl) : null; - let loading = false; - - function handleScroll(failed) { - if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return; - - const loadMore = getLoadMore(document); - if (!loadMore) return; - loading = true; - loadMore.children[0].text = "Loading..."; - - const url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); - - fetch(url) - .then(r => { - if (r.status > 299) throw new Error("error"); - return r.text(); - }) - .then(responseText => { - const doc = new DOMParser().parseFromString(responseText, "text/html"); - loadMore.remove(); - - if (masonry) { - masonry.syncHeights(); - const newMasonry = doc.querySelector(".gallery-masonry"); - if (newMasonry) { - const knownHrefs = getHrefs(".gallery-masonry .tweet-link"); - masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs))); - } - } else { - const knownHrefs = getHrefs(`${itemClass} .tweet-link`); - for (const item of doc.querySelectorAll(itemClass)) { - if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue; - isTweet ? container.appendChild(item) : insertBeforeLast(container, item); - } + loading = false; + handleScroll((failed || 0) + 1); + }); } + } - loading = false; - const newLoadMore = getLoadMore(doc); - if (newLoadMore) { - isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore); - if (masonry) newLoadMore.classList.add("masonry-visible"); - } - }) - .catch(err => { - console.warn("Something went wrong.", err); - if (failed > 3) { loadMore.children[0].text = "Error"; return; } - loading = false; - handleScroll((failed || 0) + 1); - }); - } - - window.addEventListener("scroll", () => handleScroll()); -}); + window.addEventListener("scroll", () => handleScroll()); +}; // @license-end diff --git a/public/js/widgets.js b/public/js/widgets.js deleted file mode 100644 index 7bb283a..0000000 --- a/public/js/widgets.js +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Drop-in replacement for Twitter's widgets.js - * Redirects twitter-tweet blockquotes to Nitter embeds - */ -(function () { - "use strict"; - - if (window.__nitterWidgets) return; - window.__nitterWidgets = true; - - var NITTER = new URL(document.currentScript.src).origin; - - var TWEET_RE = /(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/i; - var SELECTOR = "blockquote.twitter-tweet, blockquote.twitter-video"; - - var readyCallbacks = []; - var eventCallbacks = {}; - var isReady = false; - - function safeCall(fn, arg) { - try { - fn(arg); - } catch (e) { } - } - - function fireEvent(name, data) { - (eventCallbacks[name] || []).forEach(function (cb) { - safeCall(cb, data); - }); - } - - function parseTweetUrl(url) { - if (!url) return null; - var m = TWEET_RE.exec(url); - if (m) return { user: m[1], id: m[2] }; - m = url.match(/(\d{15,})/); - return m ? { user: null, id: m[1] } : null; - } - - function createIframe(tweet, opts) { - var url; - if (opts.videoOnly) { - url = NITTER + "/i/videos/tweet/" + tweet.id; - } else { - var path = tweet.user ? "/" + tweet.user : "/i"; - url = NITTER + path + "/status/" + tweet.id + "/embed"; - if (opts.theme) { - var theme = - opts.theme === "dark" - ? "nitter" - : opts.theme === "light" - ? "twitter" - : opts.theme; - url += "?theme=" + encodeURIComponent(theme); - } - } - - var iframe = document.createElement("iframe"); - iframe.src = url; - iframe.className = "nitter-embed-frame"; - iframe.loading = "lazy"; - iframe.setAttribute("allowtransparency", "true"); - iframe.setAttribute("frameborder", "0"); - iframe.setAttribute("scrolling", "no"); - if (opts.videoOnly) iframe.setAttribute("allowfullscreen", "true"); - - var width = opts.width || 550; - var margin = - opts.align === "center" - ? "10px auto" - : opts.align === "right" - ? "10px 0 10px auto" - : "10px 0"; - iframe.style.cssText = - "width:100%;max-width:" + - width + - "px;height:300px;" + - "border:none;display:block;margin:" + - margin; - - iframe.addEventListener("load", function () { - fireEvent("rendered", { target: iframe }); - }); - - return iframe; - } - - function processBlockquote(bq) { - if (bq.dataset.nitterProcessed) return false; - bq.dataset.nitterProcessed = "true"; - - var tweet = null; - var links = bq.querySelectorAll("a[href]"); - for (var i = 0; i < links.length && !tweet; i++) { - tweet = parseTweetUrl(links[i].href); - } - if (!tweet) return false; - - var d = bq.dataset; - var iframe = createIframe(tweet, { - width: d.mediaMaxWidth || d.width, - align: d.align, - theme: d.theme, - videoOnly: d.mediaMaxWidth !== undefined, - }); - - bq.style.display = "none"; - bq.parentNode.insertBefore(iframe, bq.nextSibling); - return true; - } - - function processEmbeds(root) { - var bqs = (root || document).querySelectorAll( - SELECTOR + ":not([data-nitter-processed])", - ); - for (var i = 0; i < bqs.length; i++) processBlockquote(bqs[i]); - } - - function handleResize(e) { - if (!Array.isArray(e.data) || e.data[0] !== "resizeIframe") return; - var h = e.data[1] && e.data[1].h; - if (!h || h <= 0 || h > 10000) return; // Cap at 10000px for sanity - - var frames = document.querySelectorAll("iframe.nitter-embed-frame"); - for (var i = 0; i < frames.length; i++) { - if (frames[i].contentWindow === e.source) { - frames[i].style.height = h + "px"; - return; - } - } - } - - function observeDOM() { - if (!window.MutationObserver || !document.body) return; - - function matches(el) { - return el.matches(SELECTOR) || el.querySelector(SELECTOR); - } - - new MutationObserver(function (muts) { - var found = muts.some(function (mut) { - return Array.prototype.some.call(mut.addedNodes, function (n) { - return n.nodeType === 1 && matches(n); - }); - }); - if (found) processEmbeds(); - }).observe(document.body, { childList: true, subtree: true }); - } - - function embedTweet(id, container, opts) { - if (!container) return Promise.reject("No container"); - var iframe = createIframe({ id: id, user: null }, opts || {}); - container.appendChild(iframe); - return Promise.resolve(iframe); - } - - var prevTwttr = window.twttr; - window.twttr = { - widgets: { - load: processEmbeds, - createTweet: embedTweet, - createTweetEmbed: embedTweet, - createVideo: embedTweet, - loaded: true, - }, - events: { - bind: function (name, cb) { - if (typeof cb !== "function") return; - if (!eventCallbacks[name]) eventCallbacks[name] = []; - eventCallbacks[name].push(cb); - }, - unbind: function (name, cb) { - if (!eventCallbacks[name]) return; - eventCallbacks[name] = cb - ? eventCallbacks[name].filter(function (f) { - return f !== cb; - }) - : []; - }, - }, - ready: function (cb) { - if (typeof cb !== "function") return; - if (isReady) cb(window.twttr); - else readyCallbacks.push(cb); - }, - _e: [], - }; - - // Process callbacks queued before load (twttr._e pattern) - if (prevTwttr && prevTwttr._e) { - prevTwttr._e.forEach(function (cb) { - safeCall(cb); - }); - } - - // Remove any Twitter scripts that snuck through - document - .querySelectorAll( - 'script[src*="platform.twitter.com"], script[src*="platform.x.com"]', - ) - .forEach(function (s) { - s.remove(); - }); - - function init() { - window.addEventListener("message", handleResize); - processEmbeds(); - observeDOM(); - isReady = true; - readyCallbacks.forEach(function (cb) { - safeCall(cb, window.twttr); - }); - readyCallbacks = []; - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -})(); diff --git a/src/api.nim b/src/api.nim index 555a699..ef3a0f9 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,8 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, strutils, sequtils, sugar +import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables import packedjson -import types, query, formatters, consts, apiutils, parser, utils -import experimental/parser +import types, query, formatters, consts, apiutils, parser +import experimental/parser as newParser # Helper to generate params object for GraphQL requests proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = @@ -11,188 +11,88 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] = if fieldToggles.len > 0: result.add ("fieldToggles", fieldToggles) -proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl = - return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid) - -proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq = - let url = apiUrl(endpoint, variables, fieldToggles, skipTid) - return ApiReq(cookie: url, oauth: url) - -proc cursorParam(after: string): string = - ## JSON-escape the user-supplied cursor so it cannot break out of the GraphQL - ## variables object (same input-validation class as the #1411 media SSRF). - if after.len > 0: "\"cursor\":" & $(%after) & "," else: "" - -proc mediaUrl(id, cursor: string): ApiReq = - result = ApiReq( - cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, "100"]), - oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, "100"]) +proc mediaUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userMediaVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserMedia ? genParams(cookieVariables), + oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) ) -proc userTweetsUrl(id: string; cursor: string): ApiReq = - return apiReq(graphUserTweetsV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) +proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + # cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), + oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) + ) + # might change this in the future pending testing + result.cookieUrl = result.oauthUrl -proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = - result = ApiReq( - cookie: apiUrl(graphUserTweetsAndReplies, userTweetsAndRepliesVars % [id, cursor], userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true) +proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = userTweetsAndRepliesVariables % [id, cursor] + oauthVariables = restIdVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), + oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) ) -proc userArticlesUrl(id: string; cursor: string): ApiReq = - result = ApiReq( - cookie: apiUrl(graphUserArticles, userArticlesVars % [id, cursor], userTweetsFieldToggles), - oauth: apiUrl(graphUserArticlesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles) - ) - -proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq = - return apiReq(graphTweet, tweetVars % [id, cursor, $mode]) - # let cookieVars = tweetDetailVars % [id, cursor] - # result = ApiReq( - # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles), - # oauth: apiUrl(graphTweet, tweetVars % [id, cursor]) - # ) - -proc userUrl(username: string): ApiReq = - let cookieVars = $(%*{"screen_name": username, "withGrokTranslatedBio": false}) - result = ApiReq( - cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles), - oauth: apiUrl(graphUserV2, $(%*{"screen_name": username})) +proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = + let + cookieVariables = tweetDetailVariables % [id, cursor] + oauthVariables = tweetVariables % [id, cursor] + result = SessionAwareUrl( + cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), + oauthUrl: graphTweet ? genParams(oauthVariables) ) proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return - let js = await fetchRaw(userUrl(username)) + let + url = graphUser ? genParams("""{"screen_name": "$1"}""" % username) + js = await fetchRaw(url, Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - url = apiReq(graphUserById, userByRestIdVars % id) - js = await fetchRaw(url) + url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) + js = await fetchRaw(url, Api.userRestId) result = parseGraphUser(js) -proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = - if username.len == 0: return - let - url = apiReq(graphAboutAccount, $(%*{"screenName": username})) - js = await fetch(url) - result = parseAboutAccount(js) - -proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq = - let url = ApiUrl(endpoint: endpoint, params: params) - ApiReq(cookie: url, oauth: url) - -proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} = - if id.len == 0: return - let - req = apiReq(graphBroadcast, $(%*{"id": id})) - js = await fetch(req) - result = parseBroadcastInfo(js) - -proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = - if mediaKey.len == 0: return - let - streamReq = restReq(restLiveStream & mediaKey) - streamJs = await fetch(streamReq) - result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr( - streamJs{"source", "location"}.getStr) - -proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} = - if id.len == 0: return - let - variables = %*{ - "id": id, - "isMetatagsQuery": false, - "withReplays": true, - "withListeners": true - } - req = apiReq(graphAudioSpace, $variables) - js = await fetch(req) - result = parseAudioSpace(js) - proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let - cursor = cursorParam(after) - url = case kind - of TimelineKind.tweets: userTweetsUrl(id, cursor) - of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) - of TimelineKind.media: mediaUrl(id, cursor) - of TimelineKind.articles: userArticlesUrl(id, cursor) - js = await fetch(url) + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + js = case kind + of TimelineKind.tweets: + await fetch(userTweetsUrl(id, cursor), Api.userTweets) + of TimelineKind.replies: + await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) + of TimelineKind.media: + await fetch(mediaUrl(id, cursor), Api.userMedia) result = parseGraphTimeline(js, after) -proc getGraphCommunity*(id: string): Future[Community] {.async.} = - if id.len == 0: return - let - url = apiReq(graphCommunity, $(%*{"communityId": id})) - js = await fetch(url) - result = parseGraphCommunity(js) - -proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} = - if id.len == 0: return - let - cursor = cursorParam(after) - url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode]) - js = await fetch(url) - result = parseGraphCommunityTimeline(js, after) - -proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} = - if id.len == 0: return - let - cursor = cursorParam(after) - url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor]) - js = await fetch(url) - result = parseGraphCommunityTimeline(js, after) - -proc communitySliceReq(endpoint, variables: string): ApiReq = - let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)]) - ApiReq(cookie: url, oauth: url) - -proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} = - if id.len == 0: return - let - cursor = if after.len > 0: $(%after) else: "null" - url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor]) - js = await fetch(url) - result = parseGraphCommunityMembers(js, after) - -proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} = - if id.len == 0: return - let - url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"]) - js = await fetch(url) - result = parseGraphCommunityMembers(js) - -proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} = - if id.len == 0 or hashtag.len == 0: return - let - safeTag = multiReplace(hashtag, ("\"", ""), ("\\", "")) - cursor = cursorParam(after) - url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag]) - js = await fetch(url) - result = parseGraphCommunityTimeline(js, after) - proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let - cursor = cursorParam(after) - url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) - js = await fetch(url) - result = parseGraphTimeline(js, after).tweets + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + url = graphListTweets ? genParams(restIdVariables % [id, cursor]) + result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list} - url = apiReq(graphListBySlug, $variables) - js = await fetch(url) - result = parseGraphList(js) + url = graphListBySlug ? genParams($variables) + result = parseGraphList(await fetch(url, Api.listBySlug)) proc getGraphList*(id: string): Future[List] {.async.} = - let - url = apiReq(graphListById, $(%*{"listId": id})) - js = await fetch(url) - result = parseGraphList(js) + let + url = graphListById ? genParams("""{"listId": "$1"}""" % id) + result = parseGraphList(await fetch(url, Api.list)) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return @@ -206,161 +106,81 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} } if after.len > 0: variables["cursor"] = % after - let - url = apiReq(graphListMembers, $variables) - js = await fetchRaw(url) - result = parseGraphListMembers(js, after) - -proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind; - after=""): Future[Result[User]] {.async.} = - if userId.len == 0: return - var variables = %*{ - "userId": userId, - "count": 20, - "includePromotedContent": false, - "withGrokTranslatedBio": true - } - if after.len > 0: - variables["cursor"] = %after - let - url = apiReq(endpoint, $variables) - js = await fetchRaw(url) - result = parseGraphFollowers(js, after, kind) - -proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} = - result = await getGraphUserConnections(userId, graphFollowers, followers, after) - -proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} = - result = await getGraphUserConnections(userId, graphFollowing, following, after) + let url = graphListMembers ? genParams($variables) + result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let - url = apiReq(graphTweetResult, $(%*{"rest_id": id})) - js = await fetch(url) + variables = """{"rest_id": "$1"}""" % id + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphTweetResult ? params, Api.tweetResult) result = parseGraphTweetResult(js) -proc getTweetByRestId*(id: string): Future[Tweet] {.async.} = +proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let - url = apiReq(graphTweetResultByRestId, tweetByRestIdVars % id, articleFieldToggles) - js = await fetch(url) - result = parseTweetByRestId(js) - -proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = - if id.len == 0: return - let - cursor = cursorParam(after) - js = await fetch(tweetDetailUrl(id, cursor, mode)) + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) result = parseGraphConversation(js, id) -proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after, mode)).replies +proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after)).replies result.beginning = after.len == 0 -proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} = - result = await getGraphTweet(id, mode=mode) +proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = + result = await getGraphTweet(id) if after.len > 0: - result.replies = await getReplies(id, after, mode) - -proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = - if id.len == 0: return - let - url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id) - js = await fetch(url) - result = parseGraphEditHistory(js, id) + result.replies = await getReplies(id, after) proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - # workaround for #1372 - let maxId = - if not after.startsWith("maxid:"): "" - else: validateNumber(after[6..^1]) - - let q = genQueryParam(query, maxId) + let q = genQueryParam(query) if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) - let product = - case query.kind - of top: "Top" - # profile media feeds (RSS, multi-user timelines) must stay chronological - of media: (if query.fromUser.len == 0: "Media" else: "Latest") - else: "Latest" - var variables = %*{ "rawQuery": q, + "query_source": "typedQuery", "count": 20, - "querySource": "typed_query", - "product": product, - "withGrokTranslatedBio":true, - "withQuickPromoteEligibilityTweetFields":false + "product": "Latest", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false } - - if after.len > 0 and maxId.len == 0: + if after.len > 0: variables["cursor"] = % after - let - url = apiReq(graphSearchTimeline, $variables) - js = await fetch(url) - result = parseGraphSearch[Tweets](js, after) + let url = graphSearchTimeline ? genParams($variables) + result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) result.query = query - # when no more items are available the API just returns the last page in - # full. this detects that and clears the page instead. - let prefix = min(64, min(after.len, result.bottom.len)) - if prefix > 0 and maxId.len == 0 and - after[0.. 0: variables["cursor"] = % after + result.beginning = false - let - url = apiReq(graphSearchTimeline, $variables) - js = await fetch(url) - result = parseGraphSearch[T](js, after) + let url = graphSearchTimeline ? genParams($variables) + result = parseGraphSearch[User](await fetch(url, Api.search), after) result.query = query -proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] = - getGraphProductSearch[User](query, "People", after) - -proc getGraphListSearch*(query: Query; after=""): Future[Result[ListSearchResult]] = - getGraphProductSearch[ListSearchResult](query, "Lists", after) - proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, "")) + let js = await fetch(mediaUrl(id, ""), Api.userMedia) result = parseGraphPhotoRail(js) -proc getGraphArticle*(id: string): Future[Article] {.async.} = - if id.len == 0: return - let - url = apiReq(graphTweetResultByRestId, articleVars % id, articleFieldToggles) - json = await fetchRaw(url) - result = parseGraphArticle(json) - -proc getGraphTweetResults*(ids: seq[string]): Future[seq[Tweet]] {.async.} = - if ids.len == 0: return - let - idsJson = "[" & ids.mapIt("\"" & it & "\"").join(",") & "]" - url = apiReq(graphTweetResultsByRestIds, articleBatchVars % idsJson, articleFieldToggles) - js = await fetch(url) - result = parseGraphTweetResults(js) - proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/apiutils.nim b/src/apiutils.nim index b2aad58..defffd1 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -1,62 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only import httpclient, asyncdispatch, options, strutils, uri, times, math, tables -import jsony, packedjson, zippy, oauth/oauth1 -import types, auth, consts, parserutils, http_pool, tid +import jsony, packedjson, zippy, oauth1 +import types, auth, consts, parserutils, http_pool import experimental/types/common const rlRemaining = "x-rate-limit-remaining" rlReset = "x-rate-limit-reset" rlLimit = "x-rate-limit-limit" - npCache = "x-np-cache" - errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} + errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -proc isCloudflareHtml*(body: string): bool = - ## Detect Cloudflare HTML error pages returned instead of JSON - if body.len < 14 or body[0] != '<': return false - body[0 ..< 14].toLowerAscii() == " from Cloudflare HTML for log diagnostics - let start = body.find("") - if start < 0: return "unknown" - let contentStart = start + 7 - let stop = body.find("", contentStart) - if stop < 0: return "unknown" - body[contentStart ..< stop].splitWhitespace().join(" ") - -var - pool: HttpPool - disableTid: bool - apiProxy: string - maxRetries: int - retryDelayMs: int - -proc setDisableTid*(disable: bool) = - disableTid = disable - -proc setMaxRetries*(n: int) = - maxRetries = n - -proc setRetryDelayMs*(ms: int) = - retryDelayMs = ms - -proc setApiProxy*(url: string) = - apiProxy = "" - if url.len > 0: - apiProxy = url.strip(chars={'/'}) & "/" - if "http" notin apiProxy: - apiProxy = "http://" & apiProxy - -proc toUrl*(req: ApiReq; sessionKind: SessionKind): Uri = - let url = case sessionKind - of oauth: req.oauth - of cookie: req.cookie - let base = case sessionKind - of oauth: "https://api.x.com" - of cookie: "https://x.com/i/api" - let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/" - parseUri(base) / (prefix & url.endpoint) ? url.params +var pool: HttpPool proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -78,41 +32,31 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getCookieHeader(authToken, ct0: string): string = "auth_token=" & authToken & "; ct0=" & ct0 -proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} = +proc genHeaders*(session: Session, url: string): HttpHeaders = result = newHttpHeaders({ - "accept": "*/*", - "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.9", + "connection": "keep-alive", "content-type": "application/json", - "origin": "https://x.com", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", "x-twitter-active-user": "yes", "x-twitter-client-language": "en", - "priority": "u=1, i" - }, titleCase=true) + "authority": "api.x.com", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", + "accept": "*/*", + "DNT": "1", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + }) case session.kind of SessionKind.oauth: - result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret) + result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) of SessionKind.cookie: + result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" result["x-twitter-auth-type"] = "OAuth2Session" result["x-csrf-token"] = session.ct0 result["cookie"] = getCookieHeader(session.authToken, session.ct0) - result["referer"] = "https://x.com/" - result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24"""" - result["sec-ch-ua-mobile"] = "?0" - result["sec-ch-ua-platform"] = "Windows" - result["sec-fetch-dest"] = "empty" - result["sec-fetch-mode"] = "cors" - result["sec-fetch-site"] = "same-origin" - if disableTid or skipTid or "/1.1/" in url.path: - result["authorization"] = bearerToken2 - else: - result["authorization"] = bearerToken - result["x-client-transaction-id"] = await genTid(url.path) -proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} = - result = await getSession(req) +proc getAndValidateSession*(api: Api): Future[Session] {.async.} = + result = await getSession(api) case result.kind of SessionKind.oauth: if result.oauthToken.len == 0: @@ -129,18 +73,9 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - let skipTid = case session.kind - of oauth: req.oauth.skipTid - of cookie: req.cookie.skipTid - let headers = await genHeaders(session, url, skipTid) - - pool.use(headers): + pool.use(genHeaders(session, $url)): template getContent = - # TODO: this is a temporary simple implementation - if apiProxy.len > 0 and "/1.1/" notin url.path: - resp = await c.get(($url).replace("https://", apiProxy)) - else: - resp = await c.get($url) + resp = await c.get($url) result = await resp.body getContent() @@ -149,45 +84,38 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true raise newException(BadClientError, "Bad client") - if resp.status == $Http404 and result.len == 0: - echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty - raise rateLimitError() - - let cacheStatus = resp.headers.getOrDefault(npCache) - if cacheStatus notin ["HIT", "STALE"] and resp.headers.hasKey(rlRemaining): + if resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) limit = parseInt(resp.headers[rlLimit]) - session.setRateLimit(req, remaining, reset, limit) + session.setRateLimit(api, remaining, reset, limit) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": result = uncompress(result, dfGzip) - if isCloudflareHtml(result): - echo "[cloudflare] ", resp.status, " (", cfTitle(result), "), API: ", url.path, ", session: ", session.pretty - raise rateLimitError() - if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors notin errorsToSkip: - echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty + echo "Fetch error, API: ", api, ", errors: ", errors if errors in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours - setLimited(session, req) + setLimited(session, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty + echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty + session.apis[api].remaining = 0 + # rate limit hit, resets after the 15 minute window raise rateLimitError() fetchBody if resp.status == $Http400: - echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty + echo "ERROR 400, ", api, ": ", result raise newException(InternalError, $url) except InternalError as e: raise e @@ -202,57 +130,48 @@ template fetchImpl(result, fetchBody) {.dirty.} = finally: release(session) -template retry(bod) {.dirty.} = - var session: Session - var retrySuccess = false - for i in 0 ..< maxRetries: - try: - session = nil - bod - retrySuccess = true - break - except RateLimitError: - let api = if session.isNil: req.cookie.endpoint - else: req.endpoint(session) - if session.isNil: - echo "[sessions] Rate limited, retrying ", api, - " request (", i, "/", maxRetries, ")..." - else: - echo "[sessions] Rate limited, retrying ", api, - " request (", i, "/", maxRetries, ")..., session: ", session.pretty - session = nil - if retryDelayMs > 0: - await sleepAsync(retryDelayMs) - if not retrySuccess: - raise rateLimitError() +template retry(bod) = + try: + bod + except RateLimitError: + echo "[sessions] Rate limited, retrying ", api, " request..." + bod -proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = +proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = retry: - var body: string - session = await getAndValidateSession(req) + var + body: string + session = await getAndValidateSession(api) - let url = req.toUrl(session.kind) + when url is SessionAwareUrl: + let url = case session.kind + of SessionKind.oauth: url.oauthUrl + of SessionKind.cookie: url.cookieUrl fetchImpl body: if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: - echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty + echo resp.status, ": ", body, " --- url: ", url result = newJNull() let error = result.getError if error != null and error notin errorsToSkip: - echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty + echo "Fetch error, API: ", api, ", error: ", error if error in {expiredToken, badToken, locked}: invalidate(session) raise rateLimitError() -proc fetchRaw*(req: ApiReq): Future[string] {.async.} = +proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = retry: - session = await getAndValidateSession(req) - let url = req.toUrl(session.kind) + var session = await getAndValidateSession(api) + + when url is SessionAwareUrl: + let url = case session.kind + of SessionKind.oauth: url.oauthUrl + of SessionKind.cookie: url.cookieUrl fetchImpl result: if not (result.startsWith('{') or result.startsWith('[')): - echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty + echo resp.status, ": ", result, " --- url: ", url result.setLen(0) diff --git a/src/auth.nim b/src/auth.nim index 259c360..734b43e 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -1,28 +1,20 @@ #SPDX-License-Identifier: AGPL-3.0-only -import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os] -import types, consts +import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] +import types import experimental/parser/session -const hourInSeconds = 60 * 60 +# max requests at a time per session to avoid race conditions +const + maxConcurrentReqs = 2 + hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false - # max requests at a time per session to avoid race conditions - maxConcurrentReqs = 2 - -proc setMaxConcurrentReqs*(reqs: int) = - if reqs > 0: - maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") -proc endpoint*(req: ApiReq; session: Session): string = - case session.kind - of oauth: req.oauth.endpoint - of cookie: req.cookie.endpoint - proc pretty*(session: Session): string = if session.isNil: return "" @@ -50,8 +42,6 @@ proc getSessionPoolHealth*(): JsonNode = oldest = now.int64 newest = 0'i64 average = 0'i64 - oauthTotal, cookieTotal = 0 - oauthLimited, cookieLimited = 0 for session in sessionPool: let created = snowflakeToEpoch(session.id) @@ -61,15 +51,8 @@ proc getSessionPoolHealth*(): JsonNode = oldest = created average += created - case session.kind - of oauth: inc oauthTotal - of cookie: inc cookieTotal - if session.limited: limited.incl session.id - case session.kind - of oauth: inc oauthLimited - of cookie: inc cookieLimited for api in session.apis.keys: let @@ -93,8 +76,6 @@ proc getSessionPoolHealth*(): JsonNode = "sessions": %*{ "total": sessionPool.len, "limited": limited.card, - "oauth": %*{"total": oauthTotal, "limited": oauthLimited}, - "cookie": %*{"total": cookieTotal, "limited": cookieLimited}, "oldest": $fromUnix(oldest), "newest": $fromUnix(newest), "average": $fromUnix(average) @@ -111,7 +92,6 @@ proc getSessionPoolDebug*(): JsonNode = for session in sessionPool: let sessionJson = %*{ - "kind": $session.kind, "apis": newJObject(), "pending": session.pending, } @@ -142,12 +122,11 @@ proc rateLimitError*(): ref RateLimitError = proc noSessionsError*(): ref NoSessionsError = newException(NoSessionsError, "no sessions available") -proc isLimited(session: Session; req: ApiReq): bool = +proc isLimited(session: Session; api: Api): bool = if session.isNil: return true - let api = req.endpoint(session) - if session.limited and api != graphUserTweetsV2: + if session.limited and api != Api.userTweets: if (epochTime().int - session.limitedAt) > hourInSeconds: session.limited = false log "resetting limit: ", session.pretty @@ -161,8 +140,8 @@ proc isLimited(session: Session; req: ApiReq): bool = else: return false -proc isReady(session: Session; req: ApiReq): bool = - not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req)) +proc isReady(session: Session; api: Api): bool = + not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) proc invalidate*(session: var Session) = if session.isNil: return @@ -177,29 +156,24 @@ proc release*(session: Session) = if session.isNil: return dec session.pending -proc getSession*(req: ApiReq): Future[Session] {.async.} = +proc getSession*(api: Api): Future[Session] {.async.} = for i in 0 ..< sessionPool.len: - if result.isReady(req): break + if result.isReady(api): break result = sessionPool.sample() - if not result.isNil and result.isReady(req): + if not result.isNil and result.isReady(api): inc result.pending else: - if result.isNil: - log "no sessions available for API: ", req.cookie.endpoint - else: - log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty + log "no sessions available for API: ", api raise noSessionsError() -proc setLimited*(session: Session; req: ApiReq) = - let api = req.endpoint(session) +proc setLimited*(session: Session; api: Api) = session.limited = true session.limitedAt = epochTime().int log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty -proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) = +proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = # avoid undefined behavior in race conditions - let api = req.endpoint(session) if api in session.apis: let rateLimit = session.apis[api] if rateLimit.reset >= reset and rateLimit.remaining < remaining: diff --git a/src/config.nim b/src/config.nim index b46a979..1b05ffe 100644 --- a/src/config.nim +++ b/src/config.nim @@ -13,8 +13,6 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = proc getConfig*(path: string): (Config, parseCfg.Config) = var cfg = loadConfig(path) - let masterRss = cfg.get("Config", "enableRSS", true) - let conf = Config( # Server address: cfg.get("Server", "address", "0.0.0.0"), @@ -39,20 +37,10 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = hmacKey: cfg.get("Config", "hmacKey", "secretkey"), base64Media: cfg.get("Config", "base64Media", false), minTokens: cfg.get("Config", "tokenCount", 10), - enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true), - enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true), - enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true), - enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true), - enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true), - enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true), + enableRss: cfg.get("Config", "enableRSS", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), - proxyAuth: cfg.get("Config", "proxyAuth", ""), - apiProxy: cfg.get("Config", "apiProxy", ""), - disableTid: cfg.get("Config", "disableTid", false), - maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2), - maxRetries: cfg.get("Config", "maxRetries", 1), - retryDelayMs: cfg.get("Config", "retryDelayMs", 150) + proxyAuth: cfg.get("Config", "proxyAuth", "") ) return (conf, cfg) diff --git a/src/consts.nim b/src/consts.nim index c88ae68..792a519 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,108 +1,116 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils +import uri, strutils const consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" - bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" - bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F" - graphUser* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName" - graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery" - graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId" - graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2" - graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2" - graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets" - graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies" - graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia" - graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2" - graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets" - graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery" - graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline" - graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail" - graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery" - graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory" - graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline" + gql = parseUri("https://api.x.com") / "graphql" - graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId" - graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug" - graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers" - graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" - graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery" - - graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery" - graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline" - graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline" - graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query" - graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query" - graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline" - - graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId" - graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds" - - graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery" - graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById" - restLiveStream* = "1.1/live_video_stream/status/" - - graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers" - graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following" + graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" + graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" + graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" + graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" + graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" + graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" + graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" + graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" + graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" + graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" + graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" + graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" + graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" + graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" + graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" gqlFeatures* = """{ - "rweb_video_screen_enabled": false, - "rweb_cashtags_enabled": true, - "profile_label_improvements_pcf_label_in_post_enabled": true, - "responsive_web_profile_redirect_enabled": false, - "rweb_tipjar_consumption_enabled": false, - "verified_phone_label_enabled": false, + "android_ad_formats_media_component_render_overlay_enabled": false, + "android_graphql_skip_api_media_color_palette": false, + "android_professional_link_spotlight_display_enabled": false, + "blue_business_profile_image_shape_enabled": false, + "commerce_android_shop_module_enabled": false, + "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, - "responsive_web_graphql_timeline_navigation_enabled": true, + "freedom_of_speech_not_reach_fetch_enabled": true, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, + "hidden_profile_likes_enabled": false, + "highlights_tweets_tab_ui_enabled": false, + "interactive_text_enabled": false, + "longform_notetweets_consumption_enabled": true, + "longform_notetweets_inline_media_enabled": true, + "longform_notetweets_rich_text_read_enabled": true, + "longform_notetweets_richtext_consumption_enabled": true, + "mobile_app_spotlight_module_enabled": false, + "responsive_web_edit_tweet_api_enabled": true, + "responsive_web_enhance_cards_enabled": false, + "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, + "responsive_web_graphql_timeline_navigation_enabled": true, + "responsive_web_media_download_video_enabled": false, + "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_article_tweet_consumption_enabled": true, + "unified_cards_destination_url_params_enabled": false, + "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "rweb_lists_timeline_redesign_enabled": true, + "spaces_2022_h2_clipping": true, + "spaces_2022_h2_spaces_communities": true, + "standardized_nudges_misinfo": true, + "subscriptions_verification_info_enabled": true, + "subscriptions_verification_info_reason_enabled": true, + "subscriptions_verification_info_verified_since_enabled": true, + "super_follow_badge_privacy_enabled": false, + "super_follow_exclusive_tweet_notifications_enabled": false, + "super_follow_tweet_api_enabled": false, + "super_follow_user_api_enabled": false, + "tweet_awards_web_tipping_enabled": false, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, + "tweetypie_unmention_optimization_enabled": false, + "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, + "verified_phone_label_enabled": false, + "vibe_api_enabled": false, + "view_counts_everywhere_api_enabled": true, "premium_content_api_read_enabled": false, "communities_web_enable_tweet_community_results_fetch": true, - "c9s_tweet_anatomy_moderator_badge_enabled": true, - "c9s_list_members_action_api_enabled": false, - "c9s_superc9s_indication_enabled": false, - "responsive_web_grok_analyze_button_fetch_trends_enabled": false, - "responsive_web_grok_analyze_post_followups_enabled": true, - "rweb_cashtags_composer_attachment_enabled": true, "responsive_web_jetfuel_frame": true, - "responsive_web_grok_share_attachment_enabled": true, - "responsive_web_grok_annotations_enabled": true, - "articles_preview_enabled": true, - "responsive_web_edit_tweet_api_enabled": true, - "rweb_conversational_replies_downvote_enabled": false, - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, - "view_counts_everywhere_api_enabled": true, - "longform_notetweets_consumption_enabled": true, - "responsive_web_twitter_article_tweet_consumption_enabled": true, - "content_disclosure_indicator_enabled": true, - "content_disclosure_ai_generated_indicator_enabled": true, - "responsive_web_grok_show_grok_translated_post": true, - "responsive_web_grok_analysis_button_from_backend": true, - "post_ctas_fetch_enabled": true, - "freedom_of_speech_not_reach_fetch_enabled": true, - "standardized_nudges_misinfo": true, - "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, - "longform_notetweets_rich_text_read_enabled": true, - "longform_notetweets_inline_media_enabled": false, + "responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_image_annotation_enabled": true, "responsive_web_grok_imagine_annotation_enabled": true, - "responsive_web_grok_community_note_auto_translation_is_enabled": true, - "responsive_web_enhance_cards_enabled": false + "rweb_tipjar_consumption_enabled": true, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "creator_subscriptions_quote_tweet_preview_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": true, + "responsive_web_grok_analyze_post_followups_enabled": true, + "rweb_video_timestamps_enabled": false, + "responsive_web_grok_share_attachment_enabled": true, + "articles_preview_enabled": true, + "immersive_video_status_linkable_timestamps": false, + "articles_api_enabled": false, + "responsive_web_grok_analysis_button_from_backend": true, + "rweb_video_screen_enabled": false, + "payments_enabled": false, + "responsive_web_profile_redirect_enabled": false, + "responsive_web_grok_show_grok_translated_post": false, + "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "profile_label_improvements_pcf_label_in_profile_enabled": false, + "grok_android_analyze_trend_fetch_enabled": false, + "grok_translations_community_note_auto_translation_is_enabled": false, + "grok_translations_post_auto_translation_is_enabled": false, + "grok_translations_community_note_translation_is_enabled": false, + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false }""".replace(" ", "").replace("\n", "") - tweetVars* = """{ + tweetVariables* = """{ "postId": "$1", $2 - "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": true, + "withBirdwatchNotes": false, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetDetailVars* = """{ + tweetDetailVariables* = """{ "focalTweetId": "$1", $2 "referrer": "profile", @@ -115,26 +123,21 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - tweetEditHistoryVars* = """{ - "tweetId": "$1", - "withQuickPromoteEligibilityTweetFields": true -}""".replace(" ", "").replace("\n", "") - - restIdVars* = """{ + restIdVariables* = """{ "rest_id": "$1", $2 - "count": $3 -}""".replace(" ", "").replace("\n", "") + "count": 20 +}""" - userMediaVars* = """{ + userMediaVariables* = """{ "userId": "$1", $2 - "count": $3, + "count": 20, "includePromotedContent": false, "withClientEventToken": false, "withBirdwatchNotes": false, "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsVars* = """{ + userTweetsVariables* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -142,7 +145,7 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userTweetsAndRepliesVars* = """{ + userTweetsAndRepliesVariables* = """{ "userId": "$1", $2 "count": 20, "includePromotedContent": false, @@ -150,70 +153,5 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") - userArticlesVars* = """{ - "userId": "$1", $2 - "count": 20, - "includePromotedContent": false, - "withVoice": true -}""".replace(" ", "").replace("\n", "") - - articleVars* = """{ - "tweetId": "$1", - "includePromotedContent": false, - "withBirdwatchNotes": true, - "withVoice": true, - "withCommunity": true -}""".replace(" ", "").replace("\n", "") - - articleBatchVars* = """{ - "tweetIds": $1, - "includePromotedContent": false, - "withBirdwatchNotes": true, - "withVoice": true, - "withCommunity": true -}""".replace(" ", "").replace("\n", "") - - articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" - - tweetByRestIdVars* = """{ - "tweetId": "$1", - "includePromotedContent": false, - "withBirdwatchNotes": false, - "withVoice": false, - "withCommunity": false -}""".replace(" ", "").replace("\n", "") - - userByRestIdVars* = """{ - "userId": "$1", - "withSafetyModeUserFields": true -}""".replace(" ", "").replace("\n", "") - - communityTweetsVars* = """{ - "communityId": "$1", $2 - "count": 20, - "displayLocation": "Community", - "rankingMode": "$3", - "withCommunity": true -}""".replace(" ", "").replace("\n", "") - - communityMediaVars* = """{ - "communityId": "$1", $2 - "count": 20, - "withCommunity": true -}""".replace(" ", "").replace("\n", "") - - communityMembersVars* = """{ - "communityId": "$1", - "cursor": $2 -}""".replace(" ", "").replace("\n", "") - - communityHashtagsVars* = """{ - "communityId": "$1", $2 - "count": 20, - "hashtags": ["$3"], - "withCommunity": true -}""".replace(" ", "").replace("\n", "") - - userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" - userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}""" + fieldToggles* = """{"withArticlePlainText":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim index e22a51f..40986f5 100644 --- a/src/experimental/parser.nim +++ b/src/experimental/parser.nim @@ -1,2 +1,2 @@ -import parser/[user, graphql, article] -export user, graphql, article +import parser/[user, graphql] +export user, graphql diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim deleted file mode 100644 index ae5de7c..0000000 --- a/src/experimental/parser/article.nim +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import std/[strutils, tables, times, options] -import jsony -import utils, graphql, ../types/article -from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia, - User, TweetStats - -proc parseGraphArticle*(json: string): Article = - if json.len == 0 or json[0] != '{': - return - - var raw: GraphArticle - try: - raw = json.fromJson(GraphArticle) - except CatchableError: - return - - let - tweet = raw.data.tweetResult.result - article = tweet.article.articleResults.result - - if article.title.len == 0: - return - - let publishedAt = article.metadata.firstPublishedAtSecs - var articleTime: DateTime - if publishedAt > 0: - articleTime = publishedAt.int64.fromUnix.utc - elif tweet.legacy.createdAt.len > 0: - articleTime = parseTwitterDate(tweet.legacy.createdAt) - - result = Article( - title: article.title, - coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl), - time: articleTime, - user: parseUserResult(tweet.core.userResults.result), - ) - - result.stats = TweetStats( - replies: tweet.legacy.replyCount, - retweets: tweet.legacy.retweetCount, - likes: tweet.legacy.favoriteCount, - ) - if tweet.views.count.len > 0: - try: result.stats.views = parseInt(tweet.views.count) - except ValueError: discard - - for blk in article.contentState.blocks: - result.paragraphs.add ArticleParagraph( - text: blk.text, - kind: blk.blockKind, - inlineStyles: blk.inlineStyleRanges, - entityRanges: blk.entityRanges, - ) - - for entry in article.contentState.entityMap: - let key = try: parseInt(entry.key) except ValueError: continue - var entity = ArticleEntity(kind: entry.value.entityKind) - case entity.kind - of "LINK": entity.url = entry.value.data.url - of "MEDIA": - for mi in entry.value.data.mediaItems: - entity.mediaIds.add mi.mediaId - entity.caption = entry.value.data.caption - of "TWEET": entity.tweetId = entry.value.data.tweetId - of "MARKDOWN": entity.markdown = entry.value.data.markdown - else: discard - result.entities[key] = entity - - for me in article.mediaEntities: - let typeName = me.mediaInfo.typeName - var media = ArticleMedia(kind: typeName) - if me.mediaInfo.videoInfo.isSome: - let variants = me.mediaInfo.videoInfo.get.variants - case typeName - of "ApiGif": - if variants.len > 0: - media.url = variants[0].url - of "ApiVideo": - var bestBitrate = -1 - for v in variants: - if v.bitrate > bestBitrate: - bestBitrate = v.bitrate - media.url = v.url - else: discard - elif typeName == "ApiImage": - media.url = getImageUrl(me.mediaInfo.originalImgUrl) - result.media[me.mediaId] = media diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 85be202..045a5d6 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -1,6 +1,6 @@ import options, strutils import jsony -import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers] +import user, ../types/[graphuser, graphlistmembers] from ../../types import User, VerifiedType, Result, Query, QueryKind proc parseUserResult*(userResult: UserResult): User = @@ -10,61 +10,27 @@ proc parseUserResult*(userResult: UserResult): User = result.verifiedType = blue if result.username.len == 0 and userResult.core.screenName.len > 0: - # Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data - # lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.). result.id = userResult.restId result.username = userResult.core.screenName result.fullname = userResult.core.name result.userPic = userResult.avatar.imageUrl.replace("_normal", "") - if userResult.banner.imageUrl.len > 0: - result.banner = userResult.banner.imageUrl & "/1500x500" - - if userResult.privacy.isSome: - result.protected = userResult.privacy.get.protected - - if userResult.location.isSome: - result.location = userResult.location.get.location - - if userResult.core.createdAt.len > 0: - result.joinDate = parseTwitterDate(userResult.core.createdAt) - if userResult.verification.isSome: let v = userResult.verification.get if v.verifiedType != VerifiedType.none: result.verifiedType = v.verifiedType - if userResult.relationshipCounts.isSome: - let rc = userResult.relationshipCounts.get - result.followers = rc.followers - result.following = rc.following - - if userResult.tweetCounts.isSome: - let tc = userResult.tweetCounts.get - result.tweets = tc.tweets - result.media = tc.mediaTweets - - if userResult.actionCounts.isSome: - result.likes = userResult.actionCounts.get.favoritesCount - if userResult.profileBio.isSome: - let bio = userResult.profileBio.get - result.bio = bio.description - result.expandUserEntities(bio.entities) + result.bio = userResult.profileBio.get.description proc parseGraphUser*(json: string): User = if json.len == 0 or json[0] != '{': return - let - raw = json.fromJson(GraphUser) - userResult = - if raw.data.userResult.isSome: raw.data.userResult.get.result - elif raw.data.user.isSome: raw.data.user.get.result - else: UserResult() + let raw = json.fromJson(GraphUser) + let userResult = raw.data.userResult.result - if userResult.unavailableReason.get("") == "Suspended" or - userResult.reason.get("") == "Suspended": + if userResult.unavailableReason.get("") == "Suspended": return User(suspended: true) result = parseUserResult(userResult) @@ -87,25 +53,3 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] = of TimelineTimelineCursor: if entry.content.cursorType == "Bottom": result.bottom = entry.content.value - -proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] = - result = Result[User]( - beginning: cursor.len == 0, - query: Query(kind: kind) - ) - - if json.len == 0 or json[0] != '{': - return - - let raw = json.fromJson(GraphFollowers) - for instruction in raw.data.user.result.timeline.timeline.instructions: - if instruction.kind == "TimelineAddEntries": - for entry in instruction.entries: - case entry.content.entryType - of TimelineTimelineItem: - let userResult = entry.content.itemContent.userResults.result - if userResult.restId.len > 0: - result.content.add parseUserResult(userResult) - of TimelineTimelineCursor: - if entry.content.cursorType == "Bottom": - result.bottom = entry.content.value diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim index db2c98d..45e6e1d 100644 --- a/src/experimental/parser/slices.nim +++ b/src/experimental/parser/slices.nim @@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; let name = $runes[rep.slice.a.succ .. rep.slice.b] symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) + result.add a(symbol & name, href = "/search?q=%23" & name) of rkMention: result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim deleted file mode 100644 index 28fccea..0000000 --- a/src/experimental/parser/tid.nim +++ /dev/null @@ -1,8 +0,0 @@ -import jsony -import ../types/tid -export TidPair - -proc parseTidPairs*(raw: string): seq[TidPair] = - result = raw.fromJson(seq[TidPair]) - if result.len == 0: - raise newException(ValueError, "Parsing pairs failed: " & raw) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 866973c..498757a 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -9,10 +9,12 @@ let unReplace = "$1@$2" htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" - htReplace = "$1$2$3" + htReplace = "$1$2$3" -proc expandUserEntities*(user: var User; ent: Entities) = - let orig = user.bio.toRunes +proc expandUserEntities(user: var User; raw: RawUser) = + let + orig = user.bio.toRunes + ent = raw.entities if ent.url.urls.len > 0: user.website = ent.url.urls[0].expandedUrl @@ -56,17 +58,15 @@ proc toUser*(raw: RawUser): User = media: raw.mediaCount, verifiedType: raw.verifiedType, protected: raw.protected, + joinDate: parseTwitterDate(raw.createdAt), banner: getBanner(raw), userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") ) - if raw.createdAt.len > 0: - result.joinDate = parseTwitterDate(raw.createdAt) - if raw.pinnedTweetIdsStr.len > 0: result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) - result.expandUserEntities(raw.entities) + result.expandUserEntities(raw) proc parseHook*(s: string; i: var int; v: var User) = var u: RawUser diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim deleted file mode 100644 index 946f8c3..0000000 --- a/src/experimental/types/article.nim +++ /dev/null @@ -1,79 +0,0 @@ -import std/options -import graphuser -from ../../types import ArticleStyle, ArticleEntityRange - -type - GraphArticle* = object - data*: tuple[tweetResult: tuple[result: TweetResultNode]] - - TweetResultNode* = object - article*: tuple[articleResults: tuple[result: ArticleResultNode]] - legacy*: TweetLegacy - core*: tuple[userResults: UserData] - views*: tuple[count: string] - - TweetLegacy* = object - createdAt*: string - replyCount*: int - retweetCount*: int - favoriteCount*: int - - ArticleResultNode* = object - title*: string - coverMedia*: tuple[mediaInfo: MediaInfoNode] - contentState*: ContentState - metadata*: tuple[firstPublishedAtSecs: int] - mediaEntities*: seq[RawMediaEntity] - - ContentState* = object - blocks*: seq[ContentBlock] - entityMap*: seq[EntityMapEntry] - - ContentBlock* = object - text*: string - blockKind*: string - inlineStyleRanges*: seq[ArticleStyle] - entityRanges*: seq[ArticleEntityRange] - - EntityMapEntry* = object - key*: string - value*: EntityMapValue - - EntityMapValue* = object - entityKind*: string - data*: EntityDataNode - - EntityDataNode* = object - url*: string - mediaItems*: seq[tuple[mediaId: string]] - tweetId*: string - markdown*: string - caption*: string - - RawMediaEntity* = object - mediaId*: string - mediaInfo*: MediaInfoNode - - MediaInfoNode* = object - typeName*: string - originalImgUrl*: string - videoInfo*: Option[VideoInfoNode] - - VideoInfoNode* = object - variants*: seq[VideoVariant] - - VideoVariant* = object - url*: string - bitrate*: int - -proc renameHook*(v: var ContentBlock; fieldName: var string) = - if fieldName == "type": - fieldName = "blockKind" - -proc renameHook*(v: var EntityMapValue; fieldName: var string) = - if fieldName == "type": - fieldName = "entityKind" - -proc renameHook*(v: var MediaInfoNode; fieldName: var string) = - if fieldName == "__typename": - fieldName = "typeName" diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim deleted file mode 100644 index ba9210b..0000000 --- a/src/experimental/types/graphfollowers.nim +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import graphlistmembers - -type - GraphFollowers* = object - data*: tuple[user: UserWrapper] - - UserWrapper = object - result*: UserResultWrapper - - UserResultWrapper = object - timeline*: tuple[timeline: graphlistmembers.Timeline] - -# Hook to normalize snake_case field from API to camelCase used by shared types -proc renameHook*(v: var Content; fieldName: var string) = - if fieldName == "user_results": - fieldName = "userResults" diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim index 9e520d8..4cb3757 100644 --- a/src/experimental/types/graphlistmembers.nim +++ b/src/experimental/types/graphlistmembers.nim @@ -7,10 +7,10 @@ type List = object membersTimeline*: tuple[timeline: Timeline] - Timeline* = object + Timeline = object instructions*: seq[Instruction] - Instruction* = object + Instruction = object kind*: string entries*: seq[tuple[content: Content]] @@ -18,7 +18,7 @@ type TimelineTimelineItem TimelineTimelineCursor - Content* = object + Content = object case entryType*: ContentEntryType of TimelineTimelineItem: itemContent*: tuple[userResults: UserData] diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index ec41f89..d732b4e 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -1,10 +1,9 @@ import options, strutils from ../../types import User, VerifiedType -import user as userType # Entities, for modern profile_bio parsing type GraphUser* = object - data*: tuple[userResult: Option[UserData], user: Option[UserData]] + data*: tuple[userResult: UserData] UserData* = object result*: UserResult @@ -16,7 +15,6 @@ type UserBio* = object description*: string - entities*: Entities UserAvatar* = object imageUrl*: string @@ -24,41 +22,15 @@ type Verification* = object verifiedType*: VerifiedType - Location* = object - location*: string - - Privacy* = object - protected*: bool - - # Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy` - # into these typed sub-objects. - RelationshipCounts* = object - followers*: int - following*: int - - TweetCounts* = object - tweets*: int - mediaTweets*: int - - ActionCounts* = object - favoritesCount*: int - UserResult* = object legacy*: User restId*: string isBlueVerified*: bool + unavailableReason*: Option[string] core*: UserCore avatar*: UserAvatar - banner*: UserAvatar - unavailableReason*: Option[string] - reason*: Option[string] - privacy*: Option[Privacy] profileBio*: Option[UserBio] verification*: Option[Verification] - location*: Option[Location] - relationshipCounts*: Option[RelationshipCounts] - tweetCounts*: Option[TweetCounts] - actionCounts*: Option[ActionCounts] proc enumHook*(s: string; v: var VerifiedType) = v = try: diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim deleted file mode 100644 index ad036d9..0000000 --- a/src/experimental/types/tid.nim +++ /dev/null @@ -1,4 +0,0 @@ -type - TidPair* = object - animationKey*: string - verification*: string diff --git a/src/formatters.nim b/src/formatters.nim index 958e518..cafaa4f 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,12 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen import std/[enumerate, re] import types, utils, query const cards = "cards.twitter.com/cards" tco = "https://t.co" - twitter = parseUri("https://x.com") + twitter = parseUri("https://twitter.com") let twRegex = re"(?<=(? 0 and "youtu" in result: - let youtubeHost = strip(prefs.replaceYouTube, chars={'/'}) - result = result.replace(ytRegex, youtubeHost) + result = result.replace(ytRegex, prefs.replaceYouTube) if prefs.replaceTwitter.len > 0: - let twitterHost = strip(prefs.replaceTwitter, chars={'/'}) if tco in result: - result = result.replace(tco, https & twitterHost & "/t.co") + result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") if "x.com" in result: - result = result.replace(xRegex, twitterHost) + result = result.replace(xRegex, prefs.replaceTwitter) result = result.replacef(xLinkRegex, a( - twitterHost & "$2", href = https & twitterHost & "$1")) + prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) if "twitter.com" in result: - result = result.replace(cards, twitterHost & "/cards") - result = result.replace(twRegex, twitterHost) + result = result.replace(cards, prefs.replaceTwitter & "/cards") + result = result.replace(twRegex, prefs.replaceTwitter) result = result.replacef(twLinkRegex, a( - twitterHost & "$2", href = https & twitterHost & "$1")) + prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): - let redditHost = strip(prefs.replaceReddit, chars={'/'}) - result = result.replace(rdShortRegex, redditHost & "/comments/") - result = result.replace(rdRegex, redditHost) - if redditHost in result and "/gallery/" in result: + result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") + result = result.replace(rdRegex, prefs.replaceReddit) + if prefs.replaceReddit in result and "/gallery/" in result: result = result.replace("/gallery/", "/comments/") if absolute.len > 0 and "href" in result: @@ -91,17 +88,7 @@ proc getM3u8Url*(content: string): string = if re.find(content, m3u8Regex, matches) != -1: result = matches[0] -proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string = - let (baseUrl, basePath) = - if manifestUrl.len > 0: - let - u = parseUri(manifestUrl) - origin = u.scheme & "://" & u.hostname - idx = manifestUrl.rfind('/') - dirPath = if idx > 8: manifestUrl[0 .. idx] else: "" - (origin, dirPath) - else: - ("https://video.twimg.com", "") +proc proxifyVideo*(manifest: string; proxy: bool): string = var replacements: seq[(string, string)] for line in manifest.splitLines: let url = @@ -109,13 +96,9 @@ proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string = elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line - let resolved = - if url.startsWith('/'): baseUrl & url - elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and - not url.startsWith("http") and ('.' in url): basePath & url - else: "" - if resolved.len > 0: - replacements.add (url, if proxy: resolved.getVidUrl else: resolved) + if url.startsWith('/'): + let path = "https://video.twimg.com" & url + replacements.add (url, if proxy: path.getVidUrl else: path) return manifest.multiReplace(replacements) proc getUserPic*(userPic: string; style=""): string = @@ -140,30 +123,25 @@ proc pageDesc*(user: User): string = "The latest tweets from " & user.fullname proc getJoinDate*(user: User): string = - if user.joinDate.year == 0: return "" user.joinDate.format("'Joined' MMMM YYYY") proc getJoinDateFull*(user: User): string = - if user.joinDate.year == 0: return "" user.joinDate.format("h:mm tt - d MMM YYYY") proc getTime*(tweet: Tweet): string = - if tweet.time.year == 0: return "" tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") proc getRfc822Time*(tweet: Tweet): string = - if tweet.time.year == 0: return "" tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") -proc getShortTime*(time: DateTime): string = - if time.year == 0: return "" +proc getShortTime*(tweet: Tweet): string = let now = now() - let since = now - time + let since = now - tweet.time - if now.year != time.year: - result = time.format("d MMM yyyy") + if now.year != tweet.time.year: + result = tweet.time.format("d MMM yyyy") elif since.inDays >= 1: - result = time.format("MMM d") + result = tweet.time.format("MMM d") elif since.inHours >= 1: result = $since.inHours & "h" elif since.inMinutes >= 1: @@ -173,33 +151,13 @@ proc getShortTime*(time: DateTime): string = else: result = "now" -proc getShortTime*(tweet: Tweet): string = - getShortTime(tweet.time) - -proc getDuration*(ms: int): string = - let - sec = int(round(ms / 1000)) - min = floorDiv(sec, 60) - hour = floorDiv(min, 60) - if hour > 0: - &"{hour}:{min mod 60:02}:{sec mod 60:02}" - else: - &"{min mod 60}:{sec mod 60:02}" - -proc getDuration*(video: Video): string = - getDuration(video.durationMs) - -proc getLink*(id: int64; username="i"; focus=true): string = - var username = username - if username.len == 0: - username = "i" - result = &"/{username}/status/{id}" - if focus: result &= "#m" - proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username - return getLink(tweet.id, username, focus) + if username.len == 0: + username = "i" + result = &"/{username}/status/{tweet.id}" + if focus: result &= "#m" proc getTwitterLink*(path: string; params: Table[string, string]): string = var @@ -227,7 +185,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getLocation*(u: User | Tweet): (string, string) = if "://" in u.location: return (u.location, "") let loc = u.location.split(":") - let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: "" + let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" (loc[0], url) proc getSuspended*(username: string): string = diff --git a/src/http_pool.nim b/src/http_pool.nim index 2553dd9..664e9a6 100644 --- a/src/http_pool.nim +++ b/src/http_pool.nim @@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) = proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = if pool.conns.len == 0: - result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy) + result = newAsyncHttpClient(headers=heads, proxy=proxy) else: result = pool.conns.pop() result.headers = heads diff --git a/src/nitter.nim b/src/nitter.nim index a76dda6..f81dc1c 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -2,15 +2,15 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a -from os import getEnv, normalizedPath +from os import getEnv import jester -import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils +import types, config, prefs, formatters, redis_cache, http_pool, auth import views/[general, about] import routes/[ - preferences, timeline, status, media, search, rss, list, community, debug, - unsupported, embed, resolver, broadcast, space, article, router_utils] + preferences, timeline, status, media, search, rss, list, debug, + unsupported, embed, resolver, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -34,62 +34,39 @@ stdout.flushFile updateDefaultPrefs(fullCfg) setCacheTimes(cfg) setHmacKey(cfg.hmacKey) -if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey": - stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " & - "set a unique random value to stop media URL signatures being forgeable.\n" - stderr.flushFile setProxyEncoding(cfg.base64Media) setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) -setApiProxy(cfg.apiProxy) -setDisableTid(cfg.disableTid) -setMaxConcurrentReqs(cfg.maxConcurrentReqs) -setMaxRetries(cfg.maxRetries) -setRetryDelayMs(cfg.retryDelayMs) initAboutPage(cfg.staticDir) waitFor initRedisPool(cfg) stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" stdout.flushFile -createArticleRouter(cfg) createUnsupportedRouter(cfg) createResolverRouter(cfg) createPrefRouter(cfg) createTimelineRouter(cfg) createListRouter(cfg) -createCommunityRouter(cfg) createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) -createBroadcastRouter(cfg) -createSpaceRouter(cfg) createDebugRouter(cfg) settings: port = Port(cfg.port) - staticDir = normalizedPath(cfg.staticDir) + staticDir = cfg.staticDir bindAddr = cfg.address reusePort = true - maxBody = 64 * 1024 routes: - before: - # Reject malformed paths - if request.path.len == 0 or request.path[0] != '/': - halt Http400 - - # skip all file URLs (except Twitter widget compatibility) - cond "." notin request.path or request.path == "/embed/Tweet.html" - applyUrlPrefs() - get "/": - resp renderMain(renderSearch(), request, cfg, requestPrefs()) + resp renderMain(renderSearch(), request, cfg, themePrefs()) get "/about": - resp renderMain(renderAbout(), request, cfg, requestPrefs()) + resp renderMain(renderAbout(), request, cfg, themePrefs()) get "/explore": redirect("/about") @@ -100,7 +77,7 @@ routes: get "/i/redirect": let url = decodeUrl(@"url") if url.len == 0: resp Http404 - redirect(replaceUrls(url, requestPrefs())) + redirect(replaceUrls(url, cookiePrefs())) error Http404: resp Http404, showError("Page not found", cfg) @@ -125,18 +102,14 @@ routes: resp Http429, showError( &"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg) - extend articleRoute, "" extend rss, "" extend status, "" extend search, "" extend timeline, "" extend media, "" extend list, "" - extend community, "" extend preferences, "" extend resolver, "" extend embed, "" - extend broadcastRoute, "" - extend spaceRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index eebca2d..700e896 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -1,20 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, options, times, math, tables, uri +import strutils, options, times, math, tables import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseGraphTweet*(js: JsonNode): Tweet - -proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = - try: parseEnum[VerifiedType](s) - except ValueError: current - -proc parseCommunityNote(js: JsonNode): string = - let subtitle = js{"subtitle"} - result = subtitle{"text"}.getStr - with entities, subtitle{"entities"}: - result = expandBirdwatchEntities(result, entities) +proc parseGraphTweet(js: JsonNode): Tweet proc parseUser(js: JsonNode; id=""): User = if js.isNull: return @@ -31,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool), + protected: js{"protected"}.getBool, joinDate: js{"created_at"}.getTime ) @@ -39,17 +29,17 @@ proc parseUser(js: JsonNode; id=""): User = result.verifiedType = blue with verifiedType, js{"verified_type"}: - result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) result.expandUserEntities(js) proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: - user = js{"user_results", "result"} + user = ? js{"user_results", "result"} if user.isNull: - if js{"core"}.notNull: + if js{"core"}.notNull and js{"legacy"}.notNull: user = js else: return @@ -61,164 +51,15 @@ proc parseGraphUser(js: JsonNode): User = # fallback to support UserMedia/recent GraphQL updates if result.username.len == 0: - result.id = user{"rest_id"}.getStr result.username = user{"core", "screen_name"}.getStr result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") - if user{"is_blue_verified"}.getBool( - user{"verification", "is_blue_verified"}.getBool(false)): + if user{"is_blue_verified"}.getBool(false): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) - -proc parseAboutAccount*(js: JsonNode): AccountInfo = - if js.isNull: return - - let user = ? js{"data", "user_result_by_screen_name", "result"} - - if user{"unavailable_reason"}.getStr == "Suspended": - result.suspended = true - return - - result = AccountInfo( - username: user{"core", "screen_name"}.getStr, - fullname: user{"core", "name"}.getStr, - joinDate: user{"core", "created_at"}.getTime, - userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""), - affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr, - ) - - if user{"is_blue_verified"}.getBool(false): - result.verifiedType = blue - with verifiedType, user{"verification", "verified_type"}: - result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType) - - with about, user{"about_profile"}: - result.basedIn = about{"account_based_in"}.getStr - result.source = about{"source"}.getStr - result.affiliateUsername = about{"affiliate_username"}.getStr - - try: - result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt - except ValueError: - discard - - with lastChange, about{"username_changes", "last_changed_at_msec"}: - result.lastUsernameChange = lastChange.getTimeFromMsStr - - with info, user{"verification_info"}: - result.isIdentityVerified = info{"is_identity_verified"}.getBool - with reason, info{"reason"}: - result.overrideVerifiedYear = reason{"override_verified_year"}.getInt - with since, reason{"verified_since_msec"}: - result.verifiedSince = since.getTimeFromMsStr - -proc parseBroadcastInfo*(js: JsonNode): Broadcast = - let bc = ? js{"data", "broadcast"} - result = Broadcast( - id: bc{"broadcast_id"}.getStr, - title: bc{"status"}.getStr, - state: bc{"state"}.getStr.toUpperAscii, - thumb: bc{"image_url"}.getStr, - mediaKey: bc{"media_key"}.getStr, - totalWatched: bc{"total_watched"}.getInt, - startTime: bc{"start_time"}.getTimeFromMs, - endTime: bc{"end_time"}.getTimeFromMs, - replayStart: bc{"edited_replay", "start_time"}.getInt, - availableForReplay: bc{"available_for_replay"}.getBool, - user: parseGraphUser(bc) - ) - -proc parseSpaceParticipant(js: JsonNode): SpaceParticipant = - result = SpaceParticipant( - userId: js{"user_results", "rest_id"}.getStr, - username: js{"twitter_screen_name"}.getStr, - displayName: js{"display_name"}.getStr, - avatarUrl: js{"avatar_url"}.getStr, - isVerified: js{"is_verified"}.getBool or - js{"user_results", "result", "is_blue_verified"}.getBool - ) - -proc parseAudioSpace*(js: JsonNode): AudioSpace = - let space = ? js{"data", "audioSpace"} - let meta = space{"metadata"} - - result = AudioSpace( - id: meta{"rest_id"}.getStr, - title: meta{"title"}.getStr, - state: meta{"state"}.getStr.toUpperAscii, - mediaKey: meta{"media_key"}.getStr, - totalLiveListeners: meta{"total_live_listeners"}.getInt, - totalReplayWatched: meta{"total_replay_watched"}.getInt, - availableForReplay: meta{"is_space_available_for_replay"}.getBool - ) - - let startedAt = meta{"started_at"}.getInt(0) - if startedAt > 0: - result.startTime = fromUnix(startedAt div 1000).utc() - - let endedAtStr = meta{"ended_at"}.getStr - if endedAtStr.len > 0: - try: - let endedAt = parseBiggestInt(endedAtStr) - if endedAt > 0: - result.endTime = fromUnix(endedAt div 1000).utc() - except ValueError: - discard - - result.creator = parseGraphUser(meta{"creator_results", "result"}) - - for admin in space{"participants", "admins"}: - result.admins.add parseSpaceParticipant(admin) - - for speaker in space{"participants", "speakers"}: - result.speakers.add parseSpaceParticipant(speaker) - -proc parseGraphCommunity*(js: JsonNode): Community = - if js.isNull: return - let c = ? js{"data", "communityResults", "result"} - - result = Community( - id: c{"rest_id"}.getStr(c{"id_str"}.getStr), - name: c{"name"}.getStr, - description: c{"description"}.getStr, - memberCount: c{"member_count"}.getInt, - joinPolicy: c{"join_policy"}.getStr, - category: c{"primary_community_topic", "topic_name"}.getStr, - banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr, - creator: parseGraphUser(c{"creator_results", "result"}), - ) - - let createdMs = c{"created_at"}.getInt(0) - if createdMs > 0: - result.createdAt = fromUnix(createdMs div 1000).utc() - - for rule in c{"rules"}: - result.rules.add CommunityRule( - name: rule{"name"}.getStr, - description: rule{"description"}.getStr - ) - - for item in c{"trending_hashtags_slice", "items"}: - let tag = item{"hashtag"}.getStr - if tag.len > 0: - result.hashtags.add tag - -proc parseListObject(js: JsonNode; owner: User): List = - List( - id: js{"id_str"}.getStr, - name: js{"name"}.getStr, - username: owner.username, - userId: owner.id, - description: js{"description"}.getStr, - members: js{"member_count"}.getInt, - banner: select( - js{"custom_banner_media", "media_info", "original_img_url"}, - js{"default_banner_media", "media_info", "original_img_url"} - ).getImageStr - ) + result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -229,17 +70,15 @@ proc parseGraphList*(js: JsonNode): List = if list.isNull: return - result = parseListObject(list, parseGraphUser(list)) - -proc parseGraphSearchList(js: JsonNode): ListSearchResult = - let owner = parseGraphUser(js) - result = ListSearchResult( - list: parseListObject(js, owner), - owner: owner, - followersContext: js{"followers_context"}.getStr + result = List( + id: list{"id_str"}.getStr, + name: list{"name"}.getStr, + username: list{"user_results", "result", "legacy", "screen_name"}.getStr, + userId: list{"user_results", "result", "rest_id"}.getStr, + description: list{"description"}.getStr, + members: list{"member_count"}.getInt, + banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr ) - for url in js{"facepile_urls"}: - result.facepiles.add url.getStr proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -295,85 +134,60 @@ proc parseVideo(js: JsonNode): Video = result.variants = parseVideoVariants(js{"video_info", "variants"}) -proc addMedia(media: var MediaEntities; photo: Photo) = - media.add Media(kind: photoMedia, photo: photo) - -proc addMedia(media: var MediaEntities; video: Video) = - media.add Media(kind: videoMedia, video: video) - -proc addMedia(media: var MediaEntities; gif: Gif) = - media.add Media(kind: gifMedia, gif: gif) - proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = with jsMedia, js{"extended_entities", "media"}: for m in jsMedia: case m.getTypeName: of "photo": - result.media.addMedia(Photo( - url: m{"media_url_https"}.getImageStr, - altText: m{"ext_alt_text"}.getStr - )) + result.photos.add m{"media_url_https"}.getImageStr of "video": - result.media.addMedia(parseVideo(m)) + result.video = some(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: if user{"id"}.getInt > 0: result.attribution = some(parseUser(user)) else: result.attribution = some(parseGraphUser(user)) - # Set attribution link from expanded_url (strip /video/N suffix) - let expanded = m{"expanded_url"}.getStr - if expanded.len > 0: - result.attributionLink = expanded.parseUri.path.replace("/video/1", "") of "animated_gif": - result.media.addMedia(Gif( + result.gif = some Gif( url: m{"video_info", "variants"}[0]{"url"}.getImageStr, - thumb: m{"media_url_https"}.getImageStr, - altText: m{"ext_alt_text"}.getStr - )) + thumb: m{"media_url_https"}.getImageStr + ) else: discard + with url, m{"url"}: + if result.text.endsWith(url.getStr): + result.text.removeSuffix(url.getStr) + result.text = result.text.strip() + proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaEntities, js{"media_entities"}: - var parsedMedia: MediaEntities for mediaEntity in mediaEntities: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": - parsedMedia.addMedia(Photo( - url: mediaInfo{"original_img_url"}.getImageStr, - altText: mediaInfo{"alt_text"}.getStr - )) + result.photos.add mediaInfo{"original_img_url"}.getImageStr of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} - parsedMedia.addMedia(Video( + result.video = some Video( available: status.getStr == "Available", thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, - title: mediaInfo{"alt_text"}.getStr, durationMs: mediaInfo{"duration_millis"}.getInt, variants: parseVideoVariants(mediaInfo{"variants"}) - )) - - # Parse source user for video attribution - with sourceUser, mediaEntity{"source_user_results", "result"}: - if result.attribution.isNone: - let expanded = mediaEntity{"expanded_url"}.getStr - if expanded.len > 0: - result.attributionLink = expanded.parseUri.path.replace("/video/1", "") - result.attribution = some(User( - id: sourceUser{"rest_id"}.getStr, - fullname: sourceUser{"core", "name"}.getStr, - userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "") - )) + ) of "ApiGif": - parsedMedia.addMedia(Gif( + result.gif = some Gif( url: mediaInfo{"variants"}[0]{"url"}.getImageStr, - thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr, - altText: mediaInfo{"alt_text"}.getStr - )) + thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr + ) else: discard - if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: - result.media = parsedMedia + # Remove media URLs from text + with mediaList, js{"legacy", "entities", "media"}: + for url in mediaList: + let expandedUrl = url{"expanded_url"}.getStr + if result.text.endsWith(expandedUrl): + result.text.removeSuffix(expandedUrl) + result.text = result.text.strip() proc parsePromoVideo(js: JsonNode): Video = result = Video( @@ -396,23 +210,14 @@ proc parsePromoVideo(js: JsonNode): Video = result.variants.add variant proc parseBroadcast(js: JsonNode): Card = - let - image = js{"broadcast_thumbnail_large"}.getImageVal - broadcastUrl = js{"broadcast_url"}.getStrVal - broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1] - streamUrl = "/i/broadcasts/" & broadcastId & "/stream" + let image = js{"broadcast_thumbnail_large"}.getImageVal result = Card( kind: broadcast, - url: "/i/broadcasts/" & broadcastId, + url: js{"broadcast_url"}.getStrVal, title: js{"broadcaster_display_name"}.getStrVal, text: js{"broadcast_title"}.getStrVal, image: image, - video: some Video( - thumb: image, - available: true, - playbackType: m3u8, - variants: @[VideoVariant(contentType: m3u8, url: streamUrl)] - ) + video: some Video(thumb: image) ) proc parseCard(js: JsonNode; urls: JsonNode): Card = @@ -451,13 +256,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url = vals{"player_url"}.getStrVal if "youtube.com" in result.url: result.url = result.url.replace("/embed/", "/watch?v=") - of audiospace: - let spaceId = vals{"id"}.getStrVal - if spaceId.len > 0: - result.url = "/i/spaces/" & spaceId - result.title = "Twitter Space" - result.text = "Click to view Space" - of unknown: + of audiospace, unknown: result.title = "This card type is not supported." else: discard @@ -468,7 +267,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = for u in ? urls: if u{"url"}.getStr == result.url: - result.url = u.getExpandedUrl(result.url) + result.url = u{"expanded_url"}.getStr break if kind in {videoDirectMessage, imageDirectMessage}: @@ -478,9 +277,8 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card = result.url.len == 0 or result.url.startsWith("card://"): result.url = getPicUrl(result.image) -proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); - replyId: int64 = 0; hasArticle = false): Tweet = - if js.isNull: return Tweet() +proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = + if js.isNull: return let time = if js{"created_at"}.notNull: js{"created_at"}.getTime @@ -503,9 +301,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); ) ) - if result.replyId == 0: - result.replyId = replyId - # fix for pinned threads if result.hasThread and result.threadId == 0: result.threadId = js{"self_thread", "id_str"}.getId @@ -523,13 +318,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); # graphql with rt, js{"retweeted_status_result", "result"}: # needed due to weird edgecase where the actual tweet data isn't included - if "legacy" in rt or "rest_id" in rt: + if "legacy" in rt: result.retweet = some parseGraphTweet(rt) return with reposts, js{"repostedStatusResults"}: with rt, reposts{"result"}: - if "legacy" in rt or "rest_id" in rt: + if "legacy" in rt: result.retweet = some parseGraphTweet(rt) return @@ -537,17 +332,15 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.media.addMedia(Photo( - url: jsCard{"binding_values", "image_large"}.getImageVal - )) + result.photos.add jsCard{"binding_values", "image_large"}.getImageVal result.poll = some parsePoll(jsCard) elif name == "amplify": - result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) - elif name.len > 0 and jsCard{"binding_values"}.notNull: + result.video = some(parsePromoVideo(jsCard{"binding_values"})) + else: result.card = some parseCard(jsCard, js{"entities", "urls"}) - result.expandTweetEntities(js, hasArticle) + result.expandTweetEntities(js) parseLegacyMediaEntities(js, result) with jsWithheld, js{"withheld_in_countries"}: @@ -564,7 +357,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull(); result.text.removeSuffix(" Learn more.") result.available = false -proc parseGraphTweet*(js: JsonNode): Tweet = +proc parseGraphTweet(js: JsonNode): Tweet = if js.kind == JNull: return Tweet() @@ -582,7 +375,7 @@ proc parseGraphTweet*(js: JsonNode): Tweet = else: discard - if "legacy" notin js and "rest_id" notin js: + if not js.hasKey("legacy"): return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) @@ -601,80 +394,12 @@ proc parseGraphTweet*(js: JsonNode): Tweet = "binding_values": %bindingObj } - var replyId: int64 = 0 - with restId, js{"reply_to_results", "rest_id"}: - replyId = restId.getId - - let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0 - - if "details" in js: - result = Tweet( - id: js{"rest_id"}.getId, - available: true, - text: js{"details", "full_text"}.getStr, - time: js{"details", "created_at_ms"}.getTimeFromMs, - replyId: js{"reply_to_results", "rest_id"}.getId, - isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool, - isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool, - stats: TweetStats( - replies: js{"counts", "reply_count"}.getInt, - retweets: js{"counts", "retweet_count"}.getInt, - likes: js{"counts", "favorite_count"}.getInt, - ) - ) - - if jsCard.kind != JNull: - let name = jsCard{"name"}.getStr - if "poll" in name: - if "image" in name: - result.media.addMedia(Photo( - url: jsCard{"binding_values", "image_large"}.getImageVal - )) - - result.poll = some parsePoll(jsCard) - elif name == "amplify": - result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) - elif name.len > 0 and jsCard{"binding_values"}.notNull: - result.card = some parseCard(jsCard, js{"url_entities"}) - - parseMediaEntities(js, result) - if result.attribution.isNone: - parseLegacyMediaEntities(js{"legacy"}, result) - - result.expandTweetEntitiesV2(js, hasArticle) - - # Strip video source URL from text (for videos from other tweets) - with mediaEntities, js{"media_entities"}: - for m in mediaEntities: - if "source_status_id_str" in m: - let mediaUrl = m{"url"}.getStr - if mediaUrl.len > 0: - let idx = result.text.rfind(mediaUrl) - if idx >= 0: - result.text = result.text[0 ..< idx].strip() - break - else: - result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle) - result.id = js{"rest_id"}.getId - - with artNode, js{"article", "article_results", "result"}: - let artTitle = artNode{"title"}.getStr - if artTitle.len > 0: - result.articlePreview = some ArticlePreview( - title: artTitle, - previewText: artNode{"preview_text"}.getStr, - coverImage: select( - artNode{"cover_media_results", "result", "media_info", "original_img_url"}, - artNode{"cover_media", "media_info", "original_img_url"} - ).getImageStr, - tweetId: result.id - ) - + result = parseTweet(js{"legacy"}, jsCard) + result.id = js{"rest_id"}.getId result.user = parseGraphUser(js{"core"}) - if result.reply.len == 0: - with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}: - result.reply = @[replyTo.getStr] + if result.replyId == 0: + result.replyId = js{"reply_to_results", "rest_id"}.getId with count, js{"views", "count"}: result.stats.views = count.getStr("0").parseInt @@ -684,58 +409,21 @@ proc parseGraphTweet*(js: JsonNode): Tweet = parseMediaEntities(js, result) - # Hide card if it's redundant with attribution (same video shown via embed) - if result.attribution.isSome and result.card.isSome: - let cardUri = get(result.card).url.parseUri - if cardUri.isTwitterUrl: - let cardPath = cardUri.path.replace("/video/1", "") - if cardPath.len > 0 and cardPath == result.attributionLink: - get(result.card).kind = hidden + if result.quote.isSome: + result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) - # Handle retweets - check both legacy and top-level paths - with reposts, js{"legacy", "repostedStatusResults"}: - with rt, reposts{"result"}: - if "legacy" in rt or "rest_id" in rt: - result.retweet = some parseGraphTweet(rt) - - with quoted, js{"quoted_status_result", "result"}: + with quoted, js{"quotedPostResults", "result"}: result.quote = some(parseGraphTweet(quoted)) - with quoted, js{"quotedPostResults"}: - if "result" in quoted: - result.quote = some(parseGraphTweet(quoted{"result"})) - else: - result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) - - with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}: - for id in ids: - result.history.add parseBiggestInt(id.getStr) - - with birdwatch, js{"birdwatch_pivot"}: - result.note = parseCommunityNote(birdwatch) - -proc getConvSection(js: JsonNode): string = - let details = select( - js{"item", "client_event_info", "details"}, - js{"item", "clientEventInfo", "details"} - ) - select( - details{"conversation_details", "conversation_section"}, - details{"conversationDetails", "conversationSection"} - ).getStr - proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = - var checkedSection = false for t in ? js{"content", "items"}: let entryId = t.getEntryId - if "tweet-" in entryId and "promoted" notin entryId: - if not checkedSection: - checkedSection = true - if getConvSection(t) == "RelatedTweet": - result.thread.related = true - - let tweet = t.getTweetResult("item") - if tweet.notNull: + if "cursor-showmore" in entryId: + let cursor = t{"item", "content", "value"} + result.thread.cursor = cursor.getStr + result.thread.hasMore = true + elif "tweet" in entryId and "promoted" notin entryId: + with tweet, t.getTweetResult("item"): result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -744,31 +432,11 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = ) if tweetDisplayType.getStr == "SelfThread": result.self = true - else: - result.thread.content.add Tweet(id: entryId.getId) - elif "cursor-showmore" in entryId: - let cursor = t{"item", "content", "value"} - result.thread.cursor = cursor.getStr - result.thread.hasMore = true proc parseGraphTweetResult*(js: JsonNode): Tweet = with tweet, js{"data", "tweet_result", "result"}: result = parseGraphTweet(tweet) -proc parseTweetByRestId*(js: JsonNode): Tweet = - with tweet, js{"data", "tweetResult", "result"}: - result = parseGraphTweet(tweet) - -proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] = - let results = js{"data", "tweetResult"} - if results.kind != JArray: return - for item in results: - let tweet = item{"result"} - if tweet.isNull: continue - let t = parseGraphTweet(tweet) - if t != nil: - result.add t - proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) @@ -784,7 +452,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if i.getTypeName == "TimelineAddEntries": for e in i{"entries"}: let entryId = e.getEntryId - if entryId.startsWith("tweet-"): + if entryId.startsWith("tweet"): let tweetResult = getTweetResult(e) if tweetResult.notNull: let tweet = parseGraphTweet(tweetResult) @@ -792,14 +460,11 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if not tweet.available: tweet.id = entryId.getId - if entryId.endsWith(tweetId): + if $tweet.id == tweetId: result.tweet = tweet else: result.before.content.add tweet - elif not entryId.endsWith(tweetId): - result.before.content.add Tweet(id: entryId.getId) - elif entryId.startsWith("conversationthread") or - entryId.startsWith("tweetdetailrelatedtweets"): + elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: result.after = thread @@ -820,60 +485,25 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.before.content.add tweet elif entryId.startsWith("cursor-bottom"): var cursorValue = select( - e{"content", "value"}, e{"content", "content", "value"}, e{"content", "itemContent", "value"} ) result.replies.bottom = cursorValue.getStr -proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = - let instructions = ? js{ - "data", "tweet_result_by_rest_id", "result", - "edit_history_timeline", "timeline", "instructions" - } - if instructions.len == 0: - return - - for i in instructions: - if i.getTypeName == "TimelineAddEntries": - for e in i{"entries"}: - let entryId = e.getEntryId - if entryId == "latestTweet": - with item, e{"content", "items"}[0]: - let tweetResult = item.getTweetResult("item") - if tweetResult.notNull: - result.latest = parseGraphTweet(tweetResult) - elif entryId == "staleTweets": - for item in e{"content", "items"}: - let tweetResult = item.getTweetResult("item") - if tweetResult.notNull: - result.history.add parseGraphTweet(tweetResult) - -iterator extractTweetsFromModuleItems(items: JsonNode): Tweet = - for item in items: - with tweetResult, item.getTweetResult("item"): - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = item.getEntryId.getId - yield tweet - -iterator extractListsFromItems(items: JsonNode): ListSearchResult = - for item in items: - with listJs, item{"item", "itemContent", "list"}: - let r = parseGraphSearchList(listJs) - if r.list.id.len > 0: - yield r - proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): - let tweet = parseGraphTweet(tweetResult) + var tweet = parseGraphTweet(tweetResult) if not tweet.available: tweet.id = e.getEntryId.getId result.add tweet return - for tweet in extractTweetsFromModuleItems(e{"content", "items"}): - result.add tweet + for item in e{"content", "items"}: + with tweetResult, item.getTweetResult("item"): + var tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = item.getEntryId.getId + result.add tweet proc parseGraphTimeline*(js: JsonNode; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) @@ -888,8 +518,12 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile = for i in instructions: if i{"moduleItems"}.notNull: - for tweet in extractTweetsFromModuleItems(i{"moduleItems"}): - result.tweets.content.add tweet + for item in i{"moduleItems"}: + with tweetResult, item.getTweetResult("item"): + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = item.getEntryId.getId + result.tweets.content.add tweet continue if i{"entries"}.notNull: @@ -924,13 +558,18 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = for i in instructions: if i{"moduleItems"}.notNull: - for t in extractTweetsFromModuleItems(i{"moduleItems"}): - let photo = extractGalleryPhoto(t) - if photo.url.len > 0: - result.add photo + for item in i{"moduleItems"}: + with tweetResult, item.getTweetResult("item"): + let t = parseGraphTweet(tweetResult) + if not t.available: + t.id = item.getEntryId.getId - if result.len == 16: - return + let photo = extractGalleryPhoto(t) + if photo.url.len > 0: + result.add photo + + if result.len == 16: + return continue if i.getTypeName != "TimelineAddEntries": @@ -947,7 +586,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail = if result.len == 16: return -proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] = +proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) let instructions = select( @@ -963,73 +602,19 @@ proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after= for e in instruction{"entries"}: let entryId = e.getEntryId when T is Tweets: - if entryId.startsWith("tweet") or entryId.startsWith("search-grid"): - for tweet in extractTweetsFromEntry(e): + if entryId.startsWith("tweet"): + with tweetRes, getTweetResult(e): + let tweet = parseGraphTweet(tweetRes) + if not tweet.available: + tweet.id = entryId.getId result.content.add tweet elif T is User: if entryId.startsWith("user"): with userRes, e{"content", "itemContent"}: result.content.add parseGraphUser(userRes) - elif T is ListSearchResult: - if entryId.startsWith("list-search"): - for list in extractListsFromItems(e{"content", "items"}): - result.content.add list if entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr - elif typ == "TimelineAddToModule": - when T is Tweets: - for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}): - result.content.add tweet - elif T is ListSearchResult: - for list in extractListsFromItems(instruction{"moduleItems"}): - result.content.add list elif typ == "TimelineReplaceEntry": if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): result.bottom = instruction{"entry", "content", "value"}.getStr - -proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline = - result = Timeline(beginning: after.len == 0) - - let communityResult = js{"data", "communityResults", "result"} - let instructions = ? select( - communityResult{"ranked_community_timeline", "timeline", "instructions"}, - communityResult{"community_media_timeline", "timeline", "instructions"}, - communityResult{"community_filtered_timeline", "timeline", "instructions"} - ) - if instructions.len == 0: - return - - for i in instructions: - if i{"entries"}.notNull: - for e in i{"entries"}: - let entryId = e.getEntryId - if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or - entryId.startsWith("communities-grid"): - for tweet in extractTweetsFromEntry(e): - result.content.add tweet - elif entryId.startsWith("cursor-bottom"): - result.bottom = e{"content", "value"}.getStr - - if after.len == 0 and i.getTypeName == "TimelinePinEntry": - var tweets = extractTweetsFromEntry(i{"entry"}) - for tweet in tweets.mitems: - tweet.pinned = true - if tweets.len > 0: - result.content.insert(tweets, 0) - -proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] = - result = Result[User](beginning: after.len == 0) - - let r = js{"data", "communityResults", "result"} - let slice = if not r{"members_slice"}.isNull: r{"members_slice"} - else: r{"moderators_slice"} - for item in slice{"items_results"}: - let user = parseGraphUser(item{"result"}) - if user.username.len > 0: - result.content.add user - - let cursor = slice{"slice_info", "next_cursor"}.getStr - if cursor.len > 0: - result.bottom = cursor - diff --git a/src/parserutils.nim b/src/parserutils.nim index 4b8e0b4..72c50e1 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -17,7 +17,7 @@ let unReplace = "$1@$2" htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)" - htReplace = "$1$2$3" + htReplace = "$1$2$3" type ReplaceSliceKind = enum @@ -72,6 +72,7 @@ template getTypeName*(js: JsonNode): string = template getEntryId*(e: JsonNode): string = e{"entryId"}.getStr(e{"entry_id"}.getStr) + template parseTime(time: string; f: static string; flen: int): DateTime = if time.len != flen: return parse(time, f, utc()) @@ -88,19 +89,11 @@ proc getTimeFromMs*(js: JsonNode): DateTime = let seconds = ms div 1000 return fromUnix(seconds).utc() -proc getTimeFromMsStr*(js: JsonNode): DateTime = - var ms: int64 - try: ms = parseBiggestInt(js.getStr("0")) - except ValueError: return - if ms == 0: return - let seconds = ms div 1000 - return fromUnix(seconds).utc() - proc getId*(id: string): int64 {.inline.} = let start = id.rfind("-") - try: - parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len]) - except ValueError: 0'i64 + if start < 0: + return parseBiggestInt(id) + return parseBiggestInt(id[start + 1 ..< id.len]) proc getId*(js: JsonNode): int64 {.inline.} = case js.kind @@ -119,9 +112,6 @@ proc getImageStr*(js: JsonNode): string = template getImageVal*(js: JsonNode): string = js{"image_value", "url"}.getImageStr -template getExpandedUrl*(js: JsonNode; fallback=""): string = - js{"expanded_url"}.getStr(js{"url"}.getStr(fallback)) - proc getCardUrl*(js: JsonNode; kind: CardKind): string = result = js{"website_url"}.getStrVal if kind == promoVideoConvo: @@ -185,16 +175,12 @@ proc extractSlice(js: JsonNode): Slice[int] = result = js["indices"][0].getInt ..< js["indices"][1].getInt proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; - textLen: int; hideTwitter = false; - hideArticle = false) = + textLen: int; hideTwitter = false) = let - url = js.getExpandedUrl + url = js["expanded_url"].getStr slice = js.extractSlice - if hideArticle and url.isTwitterUrl and "/article/" in url: - if slice.a < textLen: - result.add ReplaceSlice(kind: rkRemove, slice: slice) - elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: + if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if slice.a < textLen: result.add ReplaceSlice(kind: rkRemove, slice: slice) else: @@ -206,41 +192,28 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) = proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; textSlice: Slice[int]): string = - let - runeLen = runes.len - safeStart = max(0, textSlice.a) - safeEnd = min(runeLen, textSlice.b) - - var validRepls: seq[ReplaceSlice] - for rep in repls: - if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b: - validRepls.add rep - template extractLowerBound(i: int; idx): int = - if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart + if i > 0: repls[idx].slice.b.succ else: textSlice.a result = newStringOfCap(runes.len) - for i, rep in validRepls: - let lower = extractLowerBound(i, i - 1) - if lower < rep.slice.a: - result.add $runes[lower ..< rep.slice.a] + for i, rep in repls: + result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] case rep.kind of rkHashtag: - if rep.slice.a.succ <= rep.slice.b: - let - name = $runes[rep.slice.a.succ .. rep.slice.b] - symbol = $runes[rep.slice.a] - result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name) + let + name = $runes[rep.slice.a.succ .. rep.slice.b] + symbol = $runes[rep.slice.a] + result.add a(symbol & name, href = "/search?q=%23" & name) of rkMention: - result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display)) + result.add a($runes[rep.slice], href = rep.url, title = rep.display) of rkUrl: - result.add a(escape(rep.display), href = rep.url) + result.add a(rep.display, href = rep.url) of rkRemove: discard - let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd - if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen: + let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b + if rest.a <= rest.b: result.add $runes[rest] proc deduplicate(s: var seq[ReplaceSlice]) = @@ -265,7 +238,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = ent = ? js{"entities"} with urls, ent{"url", "urls"}: - user.website = urls[0].getExpandedUrl + user.website = urls[0]{"expanded_url"}.getStr var replacements = newSeq[ReplaceSlice]() @@ -281,7 +254,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) = .replacef(htRegex, htReplace) proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int]; - replyTo=""; hasRedundantLink=false; hasArticle=false) = + replyTo=""; hasRedundantLink=false) = let hasCard = tweet.card.isSome var replacements = newSeq[ReplaceSlice]() @@ -292,11 +265,10 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic if urlStr.len == 0 or urlStr notin text: continue - replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink, - hideArticle = hasArticle) + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) if hasCard and u{"url"}.getStr == get(tweet.card).url: - get(tweet.card).url = u.getExpandedUrl + get(tweet.card).url = u{"expanded_url"}.getStr with media, entities{"media"}: for m in media: @@ -330,7 +302,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) -proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) = +proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entities"} textRange = js{"display_text_range"} @@ -344,99 +316,23 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) = replyTo = reply.getStr tweet.reply.add replyTo - tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, - hasQuote or hasJobCard, hasArticle) - -proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; - hasRedundantLink=false; hasArticle=false) = - let hasCard = tweet.card.isSome - - var replacements = newSeq[ReplaceSlice]() - - with urls, js{"url_entities"}: - for u in urls: - let urlStr = u["url"].getStr - if urlStr.len == 0 or urlStr notin text: - continue - - replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink, - hideArticle = hasArticle) - - if hasCard and u{"url"}.getStr == get(tweet.card).url: - get(tweet.card).url = u.getExpandedUrl - - with hashtags, js{"details", "hashtag_entities"}: - for hashtag in hashtags: - replacements.extractHashtags(hashtag) - - with cashtags, js{"details", "cashtag_entities"}: - for cashtag in cashtags: - replacements.extractHashtags(cashtag) - - with mentions, js{"mention_entities"}: - for mention in mentions: - let - name = mention{"screen_name"}.getStr - slice = mention.extractSlice - idx = tweet.reply.find(name) - - if slice.a >= textSlice.a: - replacements.add ReplaceSlice(kind: rkMention, slice: slice, - url: "/" & name, display: mention["name"].getStr) - elif idx == -1 and tweet.replyId != 0: - tweet.reply.add name - - replacements.deduplicate - replacements.sort(cmp) - - tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) - -proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) = - let - textRange = js{"details", "display_text_range"} - textSlice = textRange{0}.getInt .. textRange{1}.getInt - hasQuote = "quoted_tweet_results" in js - hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails - hasAttribution = tweet.attribution.isSome - - tweet.expandTextEntitiesV2(js, tweet.text, textSlice, - hasQuote or hasJobCard or hasAttribution, - hasArticle) + tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) textSlice = 0..text.runeLen - hasAttribution = tweet.attribution.isSome - tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution) + tweet.expandTextEntities(entities, text, textSlice) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) -proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = - let runes = text.toRunes - var replacements: seq[ReplaceSlice] - - for entity in entities: - let - fromIdx = entity{"from_index"}.getInt - toIdx = entity{"to_index"}.getInt - url = entity{"ref", "url"}.getStr - if url.len > 0: - replacements.add ReplaceSlice( - kind: rkUrl, - slice: fromIdx ..< toIdx, - url: url, - display: $runes[fromIdx ..< min(toIdx, runes.len)] - ) - - replacements.sort(cmp) - result = runes.replacedWith(replacements, 0 ..< runes.len) - proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = - if t.media.len > 0: t.media[0].getThumb + if t.photos.len > 0: t.photos[0] + elif t.video.isSome: get(t.video).thumb + elif t.gif.isSome: get(t.gif).thumb elif t.card.isSome: get(t.card).image else: "" diff --git a/src/prefs.nim b/src/prefs.nim index 1a75f75..fa40a6d 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,22 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only -import tables, strutils +import tables import types, prefs_impl from config import get from parsecfg import nil -export genUpdatePrefs, genResetPrefs, genApplyPrefs +export genUpdatePrefs, genResetPrefs var defaultPrefs*: Prefs proc updateDefaultPrefs*(cfg: parsecfg.Config) = genDefaultPrefs() -proc getPrefs*(cookies, params: Table[string, string]): Prefs = +proc getPrefs*(cookies: Table[string, string]): Prefs = result = defaultPrefs - genParsePrefs(cookies) - genParsePrefs(params) + genCookiePrefs(cookies) -proc encodePrefs*(prefs: Prefs): string = - var encPairs: seq[string] - genEncodePrefs(prefs) - encPairs.join(",") +template getPref*(cookies: Table[string, string], pref): untyped = + bind genCookiePref + var res = defaultPrefs.`pref` + genCookiePref(cookies, pref, res) + res diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8519bd5..8e2ac8f 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -60,9 +60,6 @@ genPrefs: stickyProfile(checkbox, true): "Make profile sidebar stick to top" - stickyNav(checkbox, true): - "Keep navbar fixed to top" - bidiSupport(checkbox, false): "Support bidirectional text (makes clicking on tweets harder)" @@ -78,12 +75,6 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" - hideRelated(checkbox, true): - "Hide related tweets under replies" - - hideCommunityNotes(checkbox, false): - "Hide community notes" - squareAvatars(checkbox, false): "Square profile pictures" @@ -103,17 +94,6 @@ genPrefs: autoplayGifs(checkbox, true): "Autoplay gifs" - compactGallery(checkbox, false): - "Compact media gallery (no profile info or text)" - - gallerySize(select, "Medium"): - "Gallery column size" - options: @["Small", "Medium", "Large"] - - mediaView(select, "Timeline"): - "Default media view" - options: @["Timeline", "Grid", "Gallery"] - "Link replacements (blank to disable)": replaceTwitter(input, ""): "Twitter -> Nitter" @@ -147,7 +127,7 @@ macro genDefaultPrefs*(): untyped = result.add quote do: defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) -macro genParsePrefs*(prefs): untyped = +macro genCookiePrefs*(cookies): untyped = result = nnkStmtList.newTree() for pref in allPrefs(): let @@ -157,17 +137,37 @@ macro genParsePrefs*(prefs): untyped = options = pref.options result.add quote do: - if `name` in `prefs`: + if `name` in `cookies`: when `kind` == input or `name` == "theme": - result.`ident` = `prefs`[`name`] + result.`ident` = `cookies`[`name`] elif `kind` == checkbox: - result.`ident` = `prefs`[`name`] == "on" or - `prefs`[`name`] == "true" or - `prefs`[`name`] == "1" + result.`ident` = `cookies`[`name`] == "on" else: - let value = `prefs`[`name`] + let value = `cookies`[`name`] if value in `options`: result.`ident` = value +macro genCookiePref*(cookies, prefName, res): untyped = + result = nnkStmtList.newTree() + for pref in allPrefs(): + let ident = ident(pref.name) + if ident != prefName: + continue + + let + name = pref.name + kind = newLit(pref.kind) + options = pref.options + + result.add quote do: + if `name` in `cookies`: + when `kind` == input or `name` == "theme": + `res` = `cookies`[`name`] + elif `kind` == checkbox: + `res` = `cookies`[`name`] == "on" + else: + let value = `cookies`[`name`] + if value in `options`: `res` = value + macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() let req = ident("request") @@ -202,36 +202,6 @@ macro genResetPrefs*(): untyped = result.add quote do: savePref(`name`, "", `req`, expire=true) -macro genEncodePrefs*(prefs): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let - name = newLit(pref.name) - ident = ident(pref.name) - kind = newLit(pref.kind) - defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name)) - - result.add quote do: - when `kind` == checkbox: - if `prefs`.`ident` != `defaultIdent`: - if `prefs`.`ident`: - encPairs.add `name` & "=on" - else: - encPairs.add `name` & "=" - else: - if `prefs`.`ident` != `defaultIdent`: - encPairs.add `name` & "=" & `prefs`.`ident` - -macro genApplyPrefs*(params, req): untyped = - result = nnkStmtList.newTree() - for pref in allPrefs(): - let name = newLit(pref.name) - result.add quote do: - if `name` in `params`: - savePref(`name`, `params`[`name`], `req`) - else: - savePref(`name`, "", `req`, expire=true) - macro genPrefsType*(): untyped = let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) result = quote do: diff --git a/src/query.nim b/src/query.nim index ecb428d..06e1da2 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,14 +1,15 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, sequtils, tables, uri -import types, utils +import types const validFilters* = @[ "media", "images", "twimg", "videos", - "native_video", "consumer_video", "spaces", + "native_video", "consumer_video", "pro_video", "links", "news", "quote", "mentions", - "replies", "retweets", "nativeretweets" + "replies", "retweets", "nativeretweets", + "verified", "safe" ] emptyQuery* = "include:nativeretweets" @@ -20,43 +21,32 @@ template `@`(param: string): untyped = proc initQuery*(pms: Table[string, string]; name=""): Query = result = Query( kind: parseEnum[QueryKind](@"f", tweets), - view: @"view", text: @"q", filters: validFilters.filterIt("f-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms), since: @"since", until: @"until", - minLikes: validateNumber(@"min_faves") + near: @"near" ) - # articles is an internal tab kind, not a valid search filter - if result.kind == QueryKind.articles: - result.kind = tweets - if name.len > 0: result.fromUser = name.split(",") proc getMediaQuery*(name: string): Query = Query( - kind: QueryKind.media, + kind: media, filters: @["twimg", "native_video"], fromUser: @[name], sep: "OR" ) -proc getArticlesQuery*(name: string): Query = - Query( - kind: QueryKind.articles, - fromUser: @[name] - ) - proc getReplyQuery*(name: string): Query = Query( kind: replies, fromUser: @[name] ) -proc genQueryParam*(query: Query; maxId=""): string = +proc genQueryParam*(query: Query): string = var filters: seq[string] param: string @@ -65,20 +55,15 @@ proc genQueryParam*(query: Query; maxId=""): string = return query.text for i, user in query.fromUser: - if i == 0: - param = "(" - - param &= &"from:{user}" + param &= &"from:{user} " if i < query.fromUser.high: - param &= " OR " - else: - param &= ")" + param &= "OR " - if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}: - param &= " (filter:self_threads OR -filter:replies)" + if query.fromUser.len > 0 and query.kind in {posts, media}: + param &= "filter:self_threads OR -filter:replies " if "nativeretweets" notin query.excludes: - param &= " include:nativeretweets" + param &= "include:nativeretweets " for f in query.filters: filters.add "filter:" & f @@ -88,51 +73,38 @@ proc genQueryParam*(query: Query; maxId=""): string = for i in query.includes: filters.add "include:" & i - if filters.len > 0: - result = strip(param & " (" & filters.join(&" {query.sep} ") & ")") - else: - result = strip(param) - + result = strip(param & filters.join(&" {query.sep} ")) if query.since.len > 0: result &= " since:" & query.since - if query.until.len > 0 and maxId.len == 0: + if query.until.len > 0: result &= " until:" & query.until - if query.minLikes.len > 0: - result &= " min_faves:" & query.minLikes + if query.near.len > 0: + result &= &" near:\"{query.near}\" within:15mi" if query.text.len > 0: if result.len > 0: result &= " " & query.text else: result = query.text - if result.len > 0 and maxId.len > 0: - result &= " max_id:" & maxId - proc genQueryUrl*(query: Query): string = - var params: seq[string] + if query.kind notin {tweets, users}: return - if query.view.len > 0: - params.add "view=" & encodeUrl(query.view) + var params = @[&"f={query.kind}"] + if query.text.len > 0: + params.add "q=" & encodeUrl(query.text) + for f in query.filters: + params.add &"f-{f}=on" + for e in query.excludes: + params.add &"e-{e}=on" + for i in query.includes.filterIt(it != "nativeretweets"): + params.add &"i-{i}=on" - # media doubles as the profile media tab, where f isn't part of the URL scheme - if query.kind in {tweets, users, lists, top} or - (query.kind == QueryKind.media and query.fromUser.len == 0): - params.add &"f={query.kind}" - if query.text.len > 0: - params.add "q=" & encodeUrl(query.text) - for f in query.filters: - params.add &"f-{f}=on" - for e in query.excludes: - params.add &"e-{e}=on" - for i in query.includes.filterIt(it != "nativeretweets"): - params.add &"i-{i}=on" - - if query.since.len > 0: - params.add "since=" & query.since - if query.until.len > 0: - params.add "until=" & query.until - if query.minLikes.len > 0: - params.add "min_faves=" & query.minLikes + if query.since.len > 0: + params.add "since=" & query.since + if query.until.len > 0: + params.add "until=" & query.until + if query.near.len > 0: + params.add "near=" & query.near if params.len > 0: result &= params.join("&") diff --git a/src/redis_cache.nim b/src/redis_cache.nim index b9ddbcc..559d299 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -144,10 +144,9 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = else: let user = await getGraphUserById(userId) result = user.username - if result.len > 0: - await setEx(key, baseCacheTime, result) - if user.id.len > 0: - await all(cacheUserId(result, user.id), cache(user)) + await setEx(key, baseCacheTime, result) + if result.len > 0 and user.id.len > 0: + await all(cacheUserId(result, user.id), cache(user)) # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # if id == 0: return @@ -159,48 +158,6 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} = # if not result.isNil: # await cache(result) -proc cache*(data: Broadcast) {.async.} = - if data.id.len == 0: return - await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data))) - -proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} = - if id.len == 0: return - let cached = await get("bc:" & id) - if cached != redisNil: - cached.deserialize(Broadcast) - else: - result = await getBroadcastInfo(id) - await cache(result) - result.m3u8Url = await fetchBroadcastStream(result.mediaKey) - -proc cache*(data: AudioSpace) {.async.} = - if data.id.len == 0: return - let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime - await setEx("sp:" & data.id, ttl, compress(toFlatty(data))) - -proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} = - if id.len == 0: return - let cached = await get("sp:" & id) - if cached != redisNil: - cached.deserialize(AudioSpace) - else: - result = await getAudioSpace(id) - await cache(result) - result.m3u8Url = await fetchBroadcastStream(result.mediaKey) - -proc cache*(data: AccountInfo; name: string) {.async.} = - await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) - -proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} = - if username.len == 0: return - let name = toLower(username) - let cached = await get("ai:" & name) - if cached != redisNil: - cached.deserialize(AccountInfo) - elif fetch: - result = await getAboutAccount(username) - await cache(result, name) - proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return let rail = await get("pr2:" & toLower(id)) @@ -210,29 +167,6 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} = result = await getPhotoRail(id) await cache(result, id) -proc cache*(data: Community) {.async.} = - if data.id.len == 0: return - await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data))) - -proc getCachedCommunity*(id: string): Future[Community] {.async.} = - if id.len == 0: return - let cached = await get("cm:" & id) - if cached != redisNil: - cached.deserialize(Community) - else: - result = await getGraphCommunity(id) - await cache(result) - -proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} = - if id.len == 0: return - let cached = await get("cmm:" & id) - if cached != redisNil: - cached.deserialize(seq[User]) - else: - let mods = await getGraphCommunityModerators(id) - result = mods.content - await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result))) - proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = let list = if id.len == 0: redisNil else: await get("l:" & id) diff --git a/src/routes/article.nim b/src/routes/article.nim deleted file mode 100644 index 0a7d1dc..0000000 --- a/src/routes/article.nim +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, tables, strutils -import jester, karax/vdom -import ".."/[types, api] -import ../views/[article, general] -import router_utils - -export api, article, vdom, general, router_utils - -proc createArticleRouter*(cfg: Config) = - router articleRoute: - get "/i/article/@id": - cond @"id".allCharsInSet(Digits) - - let article = await getGraphArticle(@"id") - if article == nil: - resp Http404, showError("Article not found", cfg) - - var tweetIds: seq[string] - for e in article.entities.values: - if e.kind == "TWEET": - tweetIds.add e.tweetId - - var tweets = initTable[int64, Tweet]() - if tweetIds.len > 0: - try: - for t in await getGraphTweetResults(tweetIds): - tweets[t.id] = t - except CatchableError: - discard - - let - prefs = requestPrefs() - path = getPath() - html = renderArticle(article, tweets, path, prefs, @"id") - twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id" - resp renderMain(html, request, cfg, prefs, titleText=article.title, - twitterLink=twitterUrl) - - get "/@name/article/@id/?": - cond '.' notin @"name" - cond @"id".allCharsInSet(Digits) - redirect("/i/article/" & @"id") - - get "/@name/status/@id/article": - cond '.' notin @"name" - cond @"id".allCharsInSet(Digits) - redirect("/i/article/" & @"id") diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim deleted file mode 100644 index d3bb95a..0000000 --- a/src/routes/broadcast.nim +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils -import jester - -import router_utils -import ".."/[types, formatters, redis_cache] -import ../views/[general, broadcast] -import media - -export broadcast - -proc createBroadcastRouter*(cfg: Config) = - router broadcastRoute: - get "/i/broadcasts/@id": - cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) - var bc: Broadcast - try: - bc = await getCachedBroadcast(@"id") - except: - discard - - if bc.id.len == 0: - resp Http404, showError("Broadcast not found", cfg) - - let prefs = requestPrefs() - resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs, - bc.title, ogTitle=bc.title) - - get "/i/broadcasts/@id/stream": - cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) - var bc: Broadcast - try: - bc = await getCachedBroadcast(@"id") - except: - discard - - if bc.m3u8Url.len == 0: - resp Http404 - - let manifest = await safeFetch(bc.m3u8Url) - if manifest.len == 0: - resp Http502 - - resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime diff --git a/src/routes/community.nim b/src/routes/community.nim deleted file mode 100644 index b850b6b..0000000 --- a/src/routes/community.nim +++ /dev/null @@ -1,89 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strformat - -import jester - -import router_utils -import ".."/[types, redis_cache, api] -import ../views/[general, timeline, community] - -export community - -template respCommunity*(cmty: Community; title: string; nav, vnode: typed) = - if cmty.id.len == 0 or cmty.name.len == 0: - resp Http404, showError(&"""Community "{@"id"}" not found""", cfg) - - let html = renderCommunity(vnode, nav, cmty) - resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner) - -proc createCommunityRouter*(cfg: Config) = - router community: - get "/i/communities/@id/?": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor()) - respCommunity(cmty, cmty.name, - renderCommunityTabs(QueryKind.posts, cmty), - renderTimelineTweets(tl, prefs, request.path)) - - get "/i/communities/@id/latest": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor()) - respCommunity(cmty, cmty.name & " - Latest", - renderCommunityTabs(QueryKind.replies, cmty), - renderTimelineTweets(tl, prefs, request.path)) - - get "/i/communities/@id/media": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - tl = await getGraphCommunityMedia(cmty.id, getCursor()) - respCommunity(cmty, cmty.name & " - Media", - renderCommunityTabs(QueryKind.media, cmty), - renderTimelineTweets(tl, prefs, request.path)) - - get "/i/communities/@id/about": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - mods = await getCachedCommunityModerators(cmty.id) - respCommunity(cmty, cmty.name & " - About", - renderCommunityTabs(QueryKind.userList, cmty), - renderCommunityAbout(cmty, mods)) - - get "/i/communities/@id/members": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - members = await getGraphCommunityMembers(cmty.id, getCursor()) - respCommunity(cmty, cmty.name & " - Members", - renderMemberTabs(cmty, false), - renderTimelineUsers(members, prefs, request.path)) - - get "/i/communities/@id/moderators": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - mods = await getCachedCommunityModerators(cmty.id) - respCommunity(cmty, cmty.name & " - Moderators", - renderMemberTabs(cmty, true), - renderTimelineUsers(Result[User](content: mods), prefs, request.path)) - - get "/i/communities/@id/hashtag/@tag": - cond '.' notin @"id" - let - prefs = requestPrefs() - cmty = await getCachedCommunity(@"id") - tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor()) - respCommunity(cmty, cmty.name & " - #" & @"tag", - renderHashtagHeader(cmty, @"tag"), - renderTimelineTweets(tl, prefs, request.path)) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 24bba2d..994364b 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -1,79 +1,29 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, strformat, json +import asyncdispatch, strutils, strformat, options import jester, karax/vdom -import ".."/[types, api, formatters] +import ".."/[types, api] import ../views/[embed, tweet, general] -include "../views/oembed.nimf" import router_utils export api, embed, vdom, tweet, general, router_utils -proc parseTweetPath(path: string): tuple[username, id: string] = - let parts = path.split('/') - if parts.len >= 3 and parts[1] in ["status", "statuses"]: - let tweetId = parts[2].split('?')[0].split('#')[0] - if tweetId.len > 0 and tweetId.allCharsInSet(Digits): - return (parts[0], tweetId) - return ("", "") - -proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = - var path = url - if path.startsWith("https://"): - path = path[8..^1] - elif path.startsWith("http://"): - path = path[7..^1] - - const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", - "www.twitter.com/", "www.x.com/"] - - for prefix in twitterPrefixes: - if path.startsWith(prefix): - return parseTweetPath(path[prefix.len..^1]) - - let nitterPrefix = cfg.hostname & "/" - if path.startsWith(nitterPrefix): - return parseTweetPath(path[nitterPrefix.len..^1]) - - # Fall back: strip any hostname and try to parse as a tweet path. - # Handles requests where the URL's host differs from cfg.hostname - # (e.g. localhost in dev/CI, or a reverse proxy with a different domain). - let slashPos = path.find('/') - if slashPos > 0: - let afterHost = path[slashPos + 1..^1] - let parsed = parseTweetPath(afterHost) - if parsed.username.len > 0: - return parsed - - return ("", "") - proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": - let - id = @"id" - tweet = await getTweetByRestId(id) - prefs = requestPrefs() - - if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id) - - if not tweet.hasVideos: - resp renderErrorEmbed("No video in tweet", prefs, cfg, request, - tweetId=id, username=tweet.user.username) + let tweet = await getGraphTweetResult(@"id") + if tweet == nil or tweet.video.isNone: + resp Http404 resp renderVideoEmbed(tweet, cfg, request) get "/@user/status/@id/embed": let - id = @"id" - user = @"user" - tweet = await getTweetByRestId(id) - prefs = requestPrefs() + tweet = await getGraphTweetResult(@"id") + prefs = cookiePrefs() path = getPath() if tweet == nil: - resp renderErrorEmbed("Tweet not found", prefs, cfg, request, - tweetId=id, username=user) + resp Http404 resp renderTweetEmbed(tweet, path, prefs, cfg, request) @@ -84,57 +34,3 @@ proc createEmbedRouter*(cfg: Config) = redirect(&"/i/status/{id}/embed") else: resp Http404 - - get "/api/oembed": - responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) - - let - url = @"url" - format = @"format" - - if format.len > 0 and format != "json": - resp Http501, "Only JSON format is supported" - - if url.len == 0: - resp Http400, "Missing url parameter" - - let (username, tweetId) = parseTweetUrl(url, cfg) - if username.len == 0 or tweetId.len == 0: - resp Http400, "Invalid tweet URL" - - let tweet = await getTweetByRestId(tweetId) - if tweet == nil: - resp Http404 - - let - maxwidthParam = @"maxwidth" - maxwidth = if maxwidthParam.len > 0: - try: clamp(parseInt(maxwidthParam), 220, 550) - except ValueError: 550 - else: 550 - embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed" - authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username - title = stripHtml(tweet.text) - - var response = %*{ - "version": "1.0", - "type": "rich", - "provider_name": cfg.title, - "provider_url": getUrlPrefix(cfg), - "title": title, - "author_name": tweet.user.fullname, - "author_url": authorUrl, - "url": embedUrl, - "width": maxwidth, - "height": newJNull(), - "cache_age": "3153600000", - "html": renderOembedIframe(embedUrl, maxwidth) - } - - if tweet.media.len > 0: - let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb) - response["thumbnail_url"] = %thumbUrl - response["thumbnail_width"] = %maxwidth - response["thumbnail_height"] = %maxwidth - - respJson response diff --git a/src/routes/list.nim b/src/routes/list.nim index b4ab091..ac3e97e 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) = let html = renderList(vnode, timeline.query, list) - rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: "" + rss = &"""/i/lists/{@"id"}/rss""" resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) @@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/?": cond '.' notin @"id" let - prefs = requestPrefs() + prefs = cookiePrefs() list = await getCachedList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) @@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) = get "/i/lists/@id/members": cond '.' notin @"id" let - prefs = requestPrefs() + prefs = cookiePrefs() list = await getCachedList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/media.nim b/src/routes/media.nim index 3442916..de51061 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -1,7 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only import uri, strutils, httpclient, os, hashes, base64, re import asynchttpserver, asyncstreams, asyncfile, asyncnet -import asyncdispatch import jester @@ -16,9 +15,7 @@ const maxAge* = "max-age=604800" proc safeFetch*(url: string): Future[string] {.async.} = - # maxRedirects=0: the caller already validated the host, so never follow a - # redirect off the allowlisted host (would re-open the #1411 SSRF). - let client = newAsyncHttpClient(maxRedirects = 0) + let client = newAsyncHttpClient() try: result = await client.getContent(url) except: discard finally: client.close() @@ -33,79 +30,47 @@ template respond*(req: asynchttpserver.Request; headers) = proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 - let request = req.getNativeReq() - var fetchUrl = url + let + request = req.getNativeReq() + client = newAsyncHttpClient() - for attempt in 0 .. 2: - let client = newAsyncHttpClient(maxRedirects = 0) - var shouldRetry = false - try: - let resFut = client.get(fetchUrl) - let completed = await withTimeout(resFut, 5000) - if not completed: - if attempt < 2: - echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl] - shouldRetry = true - else: - echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl] - return Http504 + try: + let res = await client.get(url) + if res.status != "200 OK": + if res.status != "404 Not Found": + echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] + return Http404 + + let hashed = $hash(url) + if request.headers.getOrDefault("If-None-Match") == hashed: + return Http304 + + let contentLength = + if res.headers.hasKey("content-length"): + res.headers["content-length", 0] else: - let res = resFut.read() - if res.status != "200 OK": - if res.status == "404 Not Found": - return Http404 - if res.status.startsWith("30") and res.headers.hasKey("location"): - let location = res.headers["location", 0] - if isTwitterUrl(location): - fetchUrl = location - shouldRetry = true - continue - else: - return Http403 - if attempt < 2: - echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl] - shouldRetry = true - else: - echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl] - return Http404 - else: - let hashed = $hash(url) - if request.headers.getOrDefault("If-None-Match") == hashed: - return Http304 + "" - let contentLength = - if res.headers.hasKey("content-length"): - res.headers["content-length", 0] - else: - "" + let headers = newHttpHeaders({ + "Content-Type": res.headers["content-type", 0], + "Content-Length": contentLength, + "Cache-Control": maxAge, + "ETag": hashed + }) - let headers = newHttpHeaders({ - "content-type": res.headers["content-type", 0], - "content-length": contentLength, - "cache-control": maxAge, - "etag": hashed - }) + respond(request, headers) - respond(request, headers) - - var (hasValue, data) = (true, "") - while hasValue: - (hasValue, data) = await res.bodyStream.read() - if hasValue: - await request.client.send(data) - data.setLen 0 - return Http200 - except CatchableError: - if attempt < 2: - echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl] - shouldRetry = true - else: - echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl] - result = Http404 - finally: - client.close() - if not shouldRetry: - break + var (hasValue, data) = (true, "") + while hasValue: + (hasValue, data) = await res.bodyStream.read() + if hasValue: + await request.client.send(data) + data.setLen 0 + except HttpRequestError, ProtocolError, OSError: + echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] + result = Http404 + finally: + client.close() template check*(code): untyped = if code != Http200: @@ -121,12 +86,6 @@ proc decoded*(req: jester.Request; index: int): string = if based: decode(encoded) else: decodeUrl(encoded) -proc normalizeImgUrl*(url: var string) = - if not url.startsWith("http"): - if "twimg.com" notin url: - url.insert(twimg) - url.insert(https) - proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": @@ -134,8 +93,10 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) - cond "/amplify_video/" notin url - normalizeImgUrl(url) + if "twimg.com" notin url: + url.insert(twimg) + if not url.startsWith(https): + url.insert(https) url.add("?name=orig") let uri = parseUri(url) @@ -146,8 +107,10 @@ proc createMediaRouter*(cfg: Config) = get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) - cond "/amplify_video/" notin url - normalizeImgUrl(url) + if "twimg.com" notin url: + url.insert(twimg) + if not url.startsWith(https): + url.insert(https) let uri = parseUri(url) cond isTwitterUrl(uri) == true @@ -157,12 +120,12 @@ proc createMediaRouter*(cfg: Config) = get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) - cond isTwitterUrl(url) + cond "http" in url if getHmac(url) != request.matches[1]: resp Http403, showError("Failed to verify signature", cfg) - if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url: + if ".mp4" in url or ".ts" in url or ".m4s" in url: let code = await proxyMedia(request, url) check code @@ -176,6 +139,6 @@ proc createMediaRouter*(cfg: Config) = if ".m3u8" in url: let vid = await safeFetch(url) - content = proxifyVideo(vid, requestPrefs().proxyVideos, url) + content = proxifyVideo(vid, cookiePref(proxyVideos)) resp content, m3u8Mime diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim index 7f04de2..b8af03d 100644 --- a/src/routes/preferences.nim +++ b/src/routes/preferences.nim @@ -19,10 +19,8 @@ proc createPrefRouter*(cfg: Config) = router preferences: get "/settings": let - prefs = requestPrefs() - prefsCode = encodePrefs(prefs) - prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode - html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl) + prefs = cookiePrefs() + html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) resp renderMain(html, request, cfg, prefs, "Preferences") get "/settings/@i?": @@ -40,6 +38,3 @@ proc createPrefRouter*(cfg: Config) = savePref("hlsPlayback", "on", request) redirect(refPath()) - post "/enablemp4": - savePref("mp4Playback", "on", request) - redirect(refPath()) diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 5f074a5..1baf873 100644 --- a/src/routes/resolver.nim +++ b/src/routes/resolver.nim @@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) = router resolver: get "/cards/@card/@id": let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] - respResolved(await resolve(url, requestPrefs()), "card") + respResolved(await resolve(url, cookiePrefs()), "card") get "/t.co/@url": let url = "https://t.co/" & @"url" - respResolved(await resolve(url, requestPrefs()), "t.co") + respResolved(await resolve(url, cookiePrefs()), "t.co") diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim index 612a96b..a071a0d 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -4,19 +4,26 @@ from jester import Request, cookies import ../views/general import ".."/[utils, prefs, types] -export utils, prefs, types, uri, json +export utils, prefs, types, uri template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): - let sameSite = if cfg.useHttps: None else: Lax setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/") + httpOnly=true, secure=cfg.useHttps, sameSite=None) -template requestPrefs*(): untyped {.dirty.} = - getPrefs(cookies(request), params(request)) +template cookiePrefs*(): untyped {.dirty.} = + getPrefs(cookies(request)) + +template cookiePref*(pref): untyped {.dirty.} = + getPref(cookies(request), pref) + +template themePrefs*(): Prefs = + var res = defaultPrefs + res.theme = cookiePref(theme) + res template showError*(error: string; cfg: Config): string = - renderMain(renderError(error), request, cfg, requestPrefs(), "Error") + renderMain(renderError(error), request, cfg, themePrefs(), "Error") template getPath*(): untyped {.dirty.} = $(parseUri(request.path) ? filterParams(request.params)) @@ -36,28 +43,5 @@ template getCursor*(req: Request): string = proc getNames*(name: string): seq[string] = name.strip(chars={'/'}).split(",").filterIt(it.len > 0) -template applyUrlPrefs*() {.dirty.} = - if @"prefs".len > 0: - var prefParams = initTable[string, string]() - for pair in @"prefs".split(','): - let kv = pair.split('=', maxsplit=1) - if kv.len == 2: - prefParams[kv[0]] = kv[1] - elif kv.len == 1 and kv[0].len > 0: - prefParams[kv[0]] = "" - genApplyPrefs(prefParams, request) - - # Rebuild URL without prefs param - var params: seq[(string, string)] - for k, v in request.params: - if k != "prefs": - params.add (k, v) - - if params.len > 0: - let cleanUrl = request.getNativeReq.url ? params - redirect($cleanUrl) - else: - redirect(request.path) - template respJson*(node: JsonNode) = resp $node, "application/json" diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 038096c..b0e781d 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string = if cursor.len > 0: result &= ":" & cursor -proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} = +proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = var profile: Profile let name = req.params.getOrDefault("name") @@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future return Rss(feed: profile.user.username, cursor: "suspended") if profile.user.fullname.len > 0: - let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1)) + let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = @@ -60,15 +60,12 @@ template respRss*(rss, page) = proc createRssRouter*(cfg: Config) = router rss: get "/search/rss": - if not cfg.enableRSSSearch: - resp Http403, showError("RSS feed is disabled", cfg) + cond cfg.enableRss if @"q".len > 200: resp Http400, showError("Search input too long.", cfg) - let - prefs = requestPrefs() - query = initQuery(params(request)) - if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}: + let query = initQuery(params(request)) + if query.kind != tweets: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) let @@ -81,17 +78,15 @@ proc createRssRouter*(cfg: Config) = let tweets = await getGraphTweetSearch(query, cursor) rss.cursor = tweets.bottom - rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs) + rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) await cacheRss(key, rss) respRss(rss, "Search") get "/@name/rss": + cond cfg.enableRss cond '.' notin @"name" - if not cfg.enableRSSUserTweets: - resp Http403, showError("RSS feed is disabled", cfg) let - prefs = requestPrefs() name = @"name" key = redisKey("twitter", name, getCursor()) @@ -99,23 +94,24 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs) + rss = await timelineRss(request, cfg, Query(fromUser: @[name])) await cacheRss(key, rss) respRss(rss, "User") get "/@name/@tab/rss": + cond cfg.enableRss cond '.' notin @"name" - cond @"tab" in ["with_replies", "media", "search", "articles"] - # articles can't be approximated by search, so multi-user is unsupported - cond not (@"tab" == "articles" and ',' in @"name") - if not cfg.tabRssEnabled(@"tab"): - resp Http403, showError("RSS feed is disabled", cfg) + cond @"tab" in ["with_replies", "media", "search"] let - prefs = requestPrefs() name = @"name" tab = @"tab" - query = request.getQuery(tab, name, prefs) + query = + case tab + of "with_replies": getReplyQuery(name) + of "media": getMediaQuery(name) + of "search": initQuery(params(request), name=name) + else: Query(fromUser: @[name]) let searchKey = if tab != "search": "" else: ":" & $hash(genQueryUrl(query)) @@ -126,15 +122,14 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "User") - rss = await timelineRss(request, cfg, query, prefs) + rss = await timelineRss(request, cfg, query) await cacheRss(key, rss) respRss(rss, "User") get "/@name/lists/@slug/rss": + cond cfg.enableRss cond @"name" != "i" - if not cfg.enableRSSList: - resp Http403, showError("RSS feed is disabled", cfg) let slug = decodeUrl(@"slug") list = await getCachedList(@"name", slug) @@ -150,10 +145,8 @@ proc createRssRouter*(cfg: Config) = redirect(url) get "/i/lists/@id/rss": - if not cfg.enableRSSList: - resp Http403, showError("RSS feed is disabled", cfg) + cond cfg.enableRss let - prefs = requestPrefs() id = @"id" cursor = getCursor() key = redisKey("lists", id, cursor) @@ -166,7 +159,7 @@ proc createRssRouter*(cfg: Config) = list = await getCachedList(id=id) timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom - rss.feed = renderListRss(timeline.content, list, cfg, prefs) + rss.feed = renderListRss(timeline.content, list, cfg) await cacheRss(key, rss) respRss(rss, "List") diff --git a/src/routes/search.nim b/src/routes/search.nim index 7c7fd14..e9f991d 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,22 +19,10 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = requestPrefs() + prefs = cookiePrefs() + query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") - var query = initQuery(params(request)) - # x.com URL compat: f=user and f=list map to our kind names - # (f=live already falls back to tweets/Latest; f=media matches natively) - if @"f" == "user": - query.kind = users - elif @"f" == "list": - query.kind = lists - - # media searches support view modes, defaulting like /user/media - if query.kind == QueryKind.media and - query.view notin ["timeline", "grid", "gallery"]: - query.view = prefs.mediaView.toLowerAscii - case query.kind of users: if "," in q: @@ -45,24 +33,19 @@ proc createSearchRouter*(cfg: Config) = except InternalError: users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) - of tweets, top, QueryKind.media: + of tweets: let tweets = await getGraphTweetSearch(query, getCursor()) - rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: "" + rss = "/search/rss?" & genQueryUrl(query) resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) - of lists: - let listResults = await getGraphListSearch(query, getCursor()) - resp renderMain(renderListSearch(listResults, prefs, getPath()), - request, cfg, prefs, title) else: resp Http404, showError("Invalid search", cfg) get "/hashtag/@hash": - redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash")) + redirect("/search?q=" & encodeUrl("#" & @"hash")) get "/opensearch": - let - url = getUrlPrefix(cfg) & "/search?f=tweets&q=" - headers = {"Content-Type": "application/opensearchdescription+xml"} - resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url) + let url = getUrlPrefix(cfg) & "/search?q=" + resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, + generateOpenSearchXML(cfg.title, cfg.hostname, url) diff --git a/src/routes/space.nim b/src/routes/space.nim deleted file mode 100644 index bd956ea..0000000 --- a/src/routes/space.nim +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils -import jester - -import router_utils -import ".."/[types, formatters, redis_cache] -import ../views/[general, space] -import media - -export space - -proc createSpaceRouter*(cfg: Config) = - router spaceRoute: - get "/i/spaces/@id": - cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) - let sp = await getCachedAudioSpace(@"id") - - if sp.id.len == 0: - resp Http404, showError("Space not found", cfg) - - let prefs = requestPrefs() - resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs, - sp.title, ogTitle=sp.title) - - get "/i/spaces/@id/stream": - cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'}) - let sp = await getCachedAudioSpace(@"id") - - if sp.m3u8Url.len == 0: - resp Http404 - - let manifest = await safeFetch(sp.m3u8Url) - if manifest.len == 0: - resp Http502 - - resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime diff --git a/src/routes/status.nim b/src/routes/status.nim index 32a4447..7e89220 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,18 +21,18 @@ proc createStatusRouter*(cfg: Config) = if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) - let - prefs = requestPrefs() - sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance) + let prefs = cookiePrefs() # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(id, getCursor(), sort) + let replies = await getReplies(id, getCursor()) if replies.content.len == 0: - resp Http204 - resp $renderReplies(replies, prefs, getPath(), sort=sort) + resp Http404, "" + resp $renderReplies(replies, prefs, getPath()) - let conv = await getTweet(id, getCursor(), sort) + let conv = await getTweet(id, getCursor()) + if conv == nil: + echo "nil conv" if conv == nil or conv.tweet == nil or conv.tweet.id == 0: var error = "Tweet not found" @@ -46,19 +46,15 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.getPhotos.mapIt(it.url) + images = conv.tweet.photos video = "" - let - firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind - else: photoMedia - - if firstMediaKind == videoMedia: - images = @[conv.tweet.media[0].getThumb] + if conv.tweet.video.isSome(): + images = @[get(conv.tweet.video).thumb] video = getVideoEmbed(cfg, conv.tweet.id) - elif firstMediaKind == gifMedia: - images = @[conv.tweet.media[0].getThumb] - video = getPicUrl(conv.tweet.media[0].gif.url) + elif conv.tweet.gif.isSome(): + images = @[get(conv.tweet.gif).thumb] + video = getPicUrl(get(conv.tweet.gif).url) elif conv.tweet.card.isSome(): let card = conv.tweet.card.get() if card.image.len > 0: @@ -66,33 +62,9 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] - let - tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id - oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl) - - let html = renderConversation(conv, prefs, getPath() & "#m", sort) + let html = renderConversation(conv, prefs, getPath() & "#m") resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video, oembed=oembedUrl) - - get "/@name/status/@id/history/?": - cond '.' notin @"name" - let id = @"id" - - if id.len > 19 or id.any(c => not c.isDigit): - resp Http404, showError("Invalid tweet ID", cfg) - - let edits = await getGraphEditHistory(id) - if edits.latest == nil or edits.latest.id == 0: - resp Http404, showError("Tweet history not found", cfg) - - let - prefs = requestPrefs() - title = "History for " & pageTitle(edits.latest) - ogTitle = "Edit History for " & pageTitle(edits.latest.user) - desc = edits.latest.text - - let html = renderEditHistory(edits, prefs, getPath()) - resp renderMain(html, request, cfg, prefs, title, desc, ogTitle) + images=images, video=video) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] @@ -104,6 +76,6 @@ proc createStatusRouter*(cfg: Config) = get "/i/web/status/@id": redirect("/i/status/" & @"id") - + get "/@name/thread/@id/?": redirect("/$1/status/$2" % [@"name", @"id"]) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index c7949a2..7a10e91 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -4,39 +4,20 @@ import jester, karax/vdom import router_utils import ".."/[types, redis_cache, formatters, query, api] -import ../views/[general, profile, timeline, status, search, about_account] +import ../views/[general, profile, timeline, status, search] export vdom export uri, sequtils export router_utils export redis_cache, formatters, query, api -export profile, timeline, status, about_account +export profile, timeline, status -proc tabRssEnabled*(cfg: Config; tab: string): bool = +proc getQuery*(request: Request; tab, name: string): Query = case tab - of "": cfg.enableRSSUserTweets - of "with_replies": cfg.enableRSSUserReplies - of "media": cfg.enableRSSUserMedia - of "articles": cfg.enableRSSUserArticles - of "search": cfg.enableRSSSearch - else: false - -proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = - let view = request.params.getOrDefault("view") - case tab - of "with_replies": - result = getReplyQuery(name) - of "articles": - result = getArticlesQuery(name) - of "media": - result = getMediaQuery(name) - result.view = - if view in ["timeline", "grid", "gallery"]: view - else: prefs.mediaView.toLowerAscii - of "search": - result = initQuery(params(request), name=name) - else: - result = Query(fromUser: @[name]) + of "with_replies": getReplyQuery(name) + of "media": getMediaQuery(name) + of "search": initQuery(params(request), name=name) + else: Query(fromUser: @[name]) template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = if cond: @@ -64,23 +45,20 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] let rail = - skipIf(skipRail or query.kind == QueryKind.media, @[]): + skipIf(skipRail or query.kind == media, @[]): getCachedPhotoRail(userId) user = getCachedUser(name) - info = getCachedAccountInfo(name, fetch=false) result = case query.kind of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) - of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after) else: Profile(tweets: await getGraphTweetSearch(query, after)) result.user = await user result.photoRail = await rail - result.accountInfo = await info result.tweets.query = query @@ -127,72 +105,16 @@ proc createTimelineRouter*(cfg: Config) = get "/intent/user": respUserId() - get "/intent/follow/?": - let username = request.params.getOrDefault("screen_name") - if username.len == 0: - resp Http400, showError("Missing screen_name parameter", cfg) - redirect("/" & username) - - get "/@name/about/?": - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) - let - prefs = requestPrefs() - name = @"name" - info = await getCachedAccountInfo(name) - if info.suspended: - resp showError(getSuspended(name), cfg) - if info.username.len == 0: - resp Http404, showError("User \"" & name & "\" not found", cfg) - let aboutHtml = renderAboutAccount(info) - resp renderMain(aboutHtml, request, cfg, prefs, - "About @" & info.username) - - get "/@name/@kind/?": - cond @"kind" in ["followers", "following"] - cond '.' notin @"name" - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}) - let - prefs = requestPrefs() - name = @"name" - userId = await getUserId(name) - - if userId.len == 0: - resp Http404, showError("User \"" & name & "\" not found", cfg) - if userId == "suspended": - resp showError(getSuspended(name), cfg) - - let - cursor = getCursor() - isFollowers = @"kind" == "followers" - user = await getCachedUser(name) - results = if isFollowers: await getGraphFollowers(userId, cursor) - else: await getGraphFollowing(userId, cursor) - - if user.protected: - resp renderMain(renderProtected(user.username), request, cfg, prefs, - "Protected account @" & name) - - let - tab = if isFollowers: "Followers" else: "Following" - title = (if isFollowers: "People following @" else: "People followed by @") & name - html = renderUserList(user, results, prefs, request.path, tab) - - resp renderMain(html, request, cfg, prefs, title, - images = @[user.getUserPic("_400x400")]) - get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) - cond @"tab" in ["with_replies", "media", "search", "articles", ""] - # articles can't be approximated by search, so multi-user is unsupported - cond not (@"tab" == "articles" and ',' in @"name") + cond @"tab" in ["with_replies", "media", "search", ""] let - prefs = requestPrefs() + prefs = cookiePrefs() after = getCursor() names = getNames(@"name") - var query = request.getQuery(@"tab", @"name", prefs) + var query = request.getQuery(@"tab", @"name") if names.len != 1: query.fromUser = names @@ -200,8 +122,7 @@ proc createTimelineRouter*(cfg: Config) = if @"scroll".len > 0: if query.fromUser.len != 1: var timeline = await getGraphTweetSearch(query, after) - if timeline.content.len == 0: - resp Http204 + if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: @@ -211,9 +132,7 @@ proc createTimelineRouter*(cfg: Config) = resp $renderTimelineTweets(profile.tweets, prefs, getPath()) let rss = - if not cfg.tabRssEnabled(@"tab"): - "" - elif @"tab".len == 0: + if @"tab".len == 0: "/$1/rss" % @"name" elif @"tab" == "search": "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 345dee7..0c085d4 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,14 +10,14 @@ export feature proc createUnsupportedRouter*(cfg: Config) = router unsupported: template feature {.dirty.} = - resp renderMain(renderFeature(), request, cfg, requestPrefs()) + resp renderMain(renderFeature(), request, cfg, themePrefs()) get "/about/feature": feature() get "/login/?@i?": feature() get "/@name/lists/?": feature() get "/intent/?@i?": - cond @"i" notin ["user", "follow"] + cond @"i" notin ["user"] feature() get "/i/@i?/?@j?": diff --git a/src/sass/_article.scss b/src/sass/_article.scss deleted file mode 100644 index 9488658..0000000 --- a/src/sass/_article.scss +++ /dev/null @@ -1,278 +0,0 @@ -.article-page { - max-width: 700px; - margin: 0 auto 20px; - background-color: var(--bg_panel); - - > .top-ref { - padding-top: 20px; - } - - .article-cover { - width: 100%; - display: block; - } - - .article-body { - padding: 20px; - - > :last-child { - margin-bottom: 0; - } - - .article-title { - display: block; - font-size: 2rem; - line-height: 1.3; - margin: 0 0 10px; - color: var(--fg_color); - } - - .article-author { - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border_grey); - font-size: 14px; - - .article-author-row { - display: flex; - align-items: center; - gap: 8px; - } - - .article-avatar { - display: flex; - } - - .avatar { - width: 40px; - height: 40px; - } - - .article-author-name { - display: flex; - align-items: center; - margin-bottom: 2px; - - .fullname { - font-size: 15px; - } - - .verified-icon { - margin-left: 2px; - } - } - - .article-author-meta { - display: flex; - align-items: center; - } - - .fullname { - font-weight: 700; - color: var(--fg_color); - max-width: unset; - text-overflow: unset; - overflow: visible; - white-space: normal; - } - - .username, - .article-date-sep, - .article-date { - color: var(--fg_dark); - } - - .username { - margin-left: 0; - } - - .article-date-sep { - margin: 0 4px; - } - - .article-date { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - .tweet-stats { - margin-top: 6px; - - .tweet-stat { - padding-top: 0; - } - } - } - - > h1 { - display: block; - font-size: 1.8rem; - margin: 25px 0 15px; - } - - > h2 { - font-size: 1.4rem; - font-weight: bold; - margin: 20px 0 12px; - } - - > h3 { - font-size: 1.2rem; - font-weight: bold; - margin: 18px 0 10px; - } - - > p { - font-size: 16px; - line-height: 1.7; - margin: 16px 0; - word-wrap: break-word; - } - - .blockquote-attribution { - display: block; - margin-top: 0.5em; - } - - > blockquote { - border-left: 3px solid var(--accent); - padding-left: 16px; - margin: 16px 0; - color: var(--fg_faded); - font-size: 16px; - line-height: 1.7; - } - - > pre { - background-color: var(--bg_elements); - padding: 12px 16px; - border-radius: 6px; - overflow-x: auto; - margin: 16px 0; - - code { - font-family: monospace; - font-size: 14px; - color: var(--fg_color); - } - } - - code { - background-color: var(--bg_elements); - padding: 2px 5px; - border-radius: 3px; - font-family: monospace; - font-size: 0.9em; - } - - > ul, - > ol { - margin: 16px 0; - padding-left: 2em; - - li { - font-size: 16px; - line-height: 1.7; - margin: 6px 0; - } - } - - .article-media { - text-align: center; - margin: 20px 0; - - img, - video { - max-width: 100%; - border-radius: 12px; - } - - .article-media-caption { - color: var(--fg_faded); - font-size: 0.875rem; - margin-top: 6px; - } - } - - > a, - > p a, - > h1 a, - > h2 a, - > h3 a, - > blockquote a, - > ul a, - > ol a { - color: var(--accent); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - .article-divider { - border: none; - border-top: 1px solid var(--border_grey); - margin: 30px 0; - } - - .timeline-item { - margin: 20px 0; - border: 1px solid var(--border_grey); - border-radius: 12px; - overflow: hidden; - } - } -} - -.conversation .article-page { - max-width: 100%; - margin-bottom: 0; -} - -.article-card { - .card-image-container { - position: relative; - } - - .card-image img { - height: auto; - } - - .article-card-badge { - position: absolute; - bottom: 8px; - left: 8px; - background: rgba(0, 0, 0, 0.75); - color: #fff; - font-size: 13px; - font-weight: 700; - padding: 2px 8px; - border-radius: 4px; - } -} - -.quote .article-card { - margin: 0; - - .card-container { - border: none; - border-radius: 0; - border-top: solid 1px var(--dark_grey); - } -} - -@media (max-width: 700px) { - .article-page { - .article-body { - padding: 12px 15px 25px; - - .article-title { - font-size: 1.6rem; - } - } - } -} diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss deleted file mode 100644 index dd93606..0000000 --- a/src/sass/_broadcast.scss +++ /dev/null @@ -1,75 +0,0 @@ -.broadcast-page { - max-width: 800px; - width: 100%; - margin: 20px auto 0; -} - -.broadcast-panel { - background-color: var(--bg_panel); - border: 1px solid var(--border_grey); - border-radius: 8px; - overflow: hidden; -} - -.broadcast-player { - position: relative; - background: black; - - video, - img { - display: block; - width: 100%; - } -} - -.broadcast-info { - padding: 14px 16px; -} - -.broadcast-title { - font-size: 18px; - font-weight: bold; - margin: 0 0 12px; -} - -.broadcast-user-row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.broadcast-user { - display: flex; - align-items: center; - gap: 10px; - color: var(--fg_color); - - img { - width: 40px; - height: 40px; - border-radius: 50%; - } -} - -.broadcast-username { - color: var(--fg_dark); -} - -.broadcast-meta { - color: var(--fg_faded); - font-size: 14px; - display: flex; - flex-direction: column; - align-items: flex-end; - flex-shrink: 0; - line-height: 1.5em; -} - -.broadcast-live { - background: #e0245e; - color: white; - padding: 1px 6px; - border-radius: 3px; - font-weight: bold; - font-size: 12px; -} diff --git a/src/sass/_space.scss b/src/sass/_space.scss deleted file mode 100644 index 5fe2e7e..0000000 --- a/src/sass/_space.scss +++ /dev/null @@ -1,149 +0,0 @@ -.space-page { - max-width: 800px; - width: 100%; - margin: 20px auto 0; -} - -.space-panel { - background-color: var(--bg_panel); - border: 1px solid var(--border_grey); - border-radius: 8px; - overflow: hidden; -} - -.space-player { - position: relative; - background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%); - min-height: 140px; - display: flex; - align-items: center; - justify-content: center; - - audio { - width: 100%; - padding: 15px; - box-sizing: border-box; - - &:not([controls]) { - display: none; - } - } - - .video-overlay { - background-color: transparent; - } -} - -.space-live { - background: #e0245e; - color: white; - padding: 3px 8px; - border-radius: 4px; - font-weight: bold; - font-size: 12px; - text-transform: uppercase; - position: absolute; - top: 8px; - right: 8px; -} - -.space-info { - padding: 16px; -} - -.space-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - margin-bottom: 16px; -} - -.space-title { - font-size: 18px; - font-weight: bold; - margin: 0; - line-height: 1.3; - flex: 1; -} - -.space-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - flex-shrink: 0; - font-size: 14px; - color: var(--fg_faded); -} - -.listener-count { - color: var(--fg_color); -} - -.space-state { - color: var(--fg_dark); -} - -.space-participants { - border-top: 1px solid var(--border_grey); - padding-top: 12px; -} - -.space-participant { - margin-bottom: 10px; - - a { - display: flex; - align-items: center; - gap: 10px; - color: var(--fg_color); - padding: 6px 0; - } - - img { - width: 40px; - height: 40px; - border-radius: 50%; - flex-shrink: 0; - } -} - -.participant-info { - min-width: 0; -} - -.participant-name { - display: flex; - align-items: center; - gap: 4px; - - strong { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .verified-icon { - margin-bottom: 0; - position: relative; - top: -2px; - } -} - -.host-badge { - background: var(--accent); - color: white; - padding: 2px 7px; - border-radius: 3px; - font-size: 11px; - font-weight: 600; - line-height: 1; - position: relative; - top: 1px; -} - -.participant-username { - color: var(--fg_dark); - font-size: 13px; -} diff --git a/src/sass/general.scss b/src/sass/general.scss index e6247d6..9feb3d3 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -1,40 +1,39 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; .panel-container { - margin: auto; - font-size: 130%; + margin: auto; + font-size: 130%; } .error-panel { - @include center-panel(var(--error_red)); - text-align: center; + @include center-panel(var(--error_red)); + text-align: center; } .search-bar > form { - @include center-panel(var(--darkest_grey)); + @include center-panel(var(--darkest_grey)); - button { - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 3px; - cursor: pointer; - font-weight: bold; - width: 30px; - height: 30px; - padding: 0px 5px 1px 8px; - } + button { + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 3px; + cursor: pointer; + font-weight: bold; + width: 30px; + height: 30px; + } - input { - font-size: 16px; - width: 100%; - background: var(--bg_elements); - color: var(--fg_color); - border: 0; - border-radius: 4px; - padding: 4px; - margin-right: 8px; - height: unset; - } + input { + font-size: 16px; + width: 100%; + background: var(--bg_elements); + color: var(--fg_color); + border: 0; + border-radius: 4px; + padding: 4px; + margin-right: 8px; + height: unset; + } } diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 5fde51a..94e11ee 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -66,7 +66,18 @@ } #search-panel-toggle:checked ~ .search-panel { - max-height: 380px !important; + @if $rows == 6 { + max-height: 200px !important; + } + @if $rows == 5 { + max-height: 300px !important; + } + @if $rows == 4 { + max-height: 300px !important; + } + @if $rows == 3 { + max-height: 365px !important; + } } } } diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss index 127cccb..0c95ff6 100644 --- a/src/sass/include/_variables.scss +++ b/src/sass/include/_variables.scss @@ -1,43 +1,46 @@ // colors -$bg_color: #0f0f0f; -$fg_color: #f8f8f2; -$fg_faded: #f8f8f2cf; -$fg_dark: #ff6c60; -$fg_nav: #ff6c60; +$bg_color: #0F0F0F; +$fg_color: #F8F8F2; +$fg_faded: #F8F8F2CF; +$fg_dark: #FF6C60; +$fg_nav: #FF6C60; $bg_panel: #161616; $bg_elements: #121212; -$bg_overlays: #1f1f1f; -$bg_hover: #1a1a1a; +$bg_overlays: #1F1F1F; +$bg_hover: #1A1A1A; $grey: #888889; $dark_grey: #404040; $darker_grey: #282828; $darkest_grey: #222222; -$border_grey: #3e3e35; +$border_grey: #3E3E35; -$accent: #ff6c60; -$accent_light: #ffaca0; -$accent_dark: #8a3731; -$accent_border: #ff6c6091; +$accent: #FF6C60; +$accent_light: #FFACA0; +$accent_dark: #8A3731; +$accent_border: #FF6C6091; -$play_button: #d8574d; -$play_button_hover: #ff6c60; +$play_button: #D8574D; +$play_button_hover: #FF6C60; -$more_replies_dots: #ad433b; -$error_red: #420a05; +$more_replies_dots: #AD433B; +$error_red: #420A05; -$verified_blue: #1da1f2; -$verified_business: #fac82b; -$verified_government: #c1b6a4; +$verified_blue: #1DA1F2; +$verified_business: #FAC82B; +$verified_government: #C1B6A4; $icon_text: $fg_color; $tab: $fg_color; $tab_selected: $accent; -$shadow: rgba(0, 0, 0, 0.6); -$shadow_dark: rgba(0, 0, 0, 0.2); +$shadow: rgba(0,0,0,.6); +$shadow_dark: rgba(0,0,0,.2); //fonts -$font_0: sans-serif; -$font_1: fontello; +$font_0: Helvetica Neue; +$font_1: Helvetica; +$font_2: Arial; +$font_3: sans-serif; +$font_4: fontello; diff --git a/src/sass/index.scss b/src/sass/index.scss index 404f7d5..6cab48e 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -1,220 +1,180 @@ -@import "_variables"; +@import '_variables'; -@import "tweet/_base"; -@import "profile/_base"; -@import "general"; -@import "navbar"; -@import "inputs"; -@import "timeline"; -@import "search"; -@import "broadcast"; -@import "space"; -@import "_article"; +@import 'tweet/_base'; +@import 'profile/_base'; +@import 'general'; +@import 'navbar'; +@import 'inputs'; +@import 'timeline'; +@import 'search'; body { - // colors - --bg_color: #{$bg_color}; - --fg_color: #{$fg_color}; - --fg_faded: #{$fg_faded}; - --fg_dark: #{$fg_dark}; - --fg_nav: #{$fg_nav}; + // colors + --bg_color: #{$bg_color}; + --fg_color: #{$fg_color}; + --fg_faded: #{$fg_faded}; + --fg_dark: #{$fg_dark}; + --fg_nav: #{$fg_nav}; - --bg_panel: #{$bg_panel}; - --bg_elements: #{$bg_elements}; - --bg_overlays: #{$bg_overlays}; - --bg_hover: #{$bg_hover}; + --bg_panel: #{$bg_panel}; + --bg_elements: #{$bg_elements}; + --bg_overlays: #{$bg_overlays}; + --bg_hover: #{$bg_hover}; - --grey: #{$grey}; - --dark_grey: #{$dark_grey}; - --darker_grey: #{$darker_grey}; - --darkest_grey: #{$darkest_grey}; - --border_grey: #{$border_grey}; + --grey: #{$grey}; + --dark_grey: #{$dark_grey}; + --darker_grey: #{$darker_grey}; + --darkest_grey: #{$darkest_grey}; + --border_grey: #{$border_grey}; - --accent: #{$accent}; - --accent_light: #{$accent_light}; - --accent_dark: #{$accent_dark}; - --accent_border: #{$accent_border}; + --accent: #{$accent}; + --accent_light: #{$accent_light}; + --accent_dark: #{$accent_dark}; + --accent_border: #{$accent_border}; - --play_button: #{$play_button}; - --play_button_hover: #{$play_button_hover}; + --play_button: #{$play_button}; + --play_button_hover: #{$play_button_hover}; - --more_replies_dots: #{$more_replies_dots}; - --error_red: #{$error_red}; + --more_replies_dots: #{$more_replies_dots}; + --error_red: #{$error_red}; - --verified_blue: #{$verified_blue}; - --verified_business: #{$verified_business}; - --verified_government: #{$verified_government}; - --icon_text: #{$icon_text}; + --verified_blue: #{$verified_blue}; + --verified_business: #{$verified_business}; + --verified_government: #{$verified_government}; + --icon_text: #{$icon_text}; - --tab: #{$fg_color}; - --tab_selected: #{$accent}; + --tab: #{$fg_color}; + --tab_selected: #{$accent}; - --profile_stat: #{$fg_color}; + --profile_stat: #{$fg_color}; - background-color: var(--bg_color); - color: var(--fg_color); - font-family: $font_0, $font_1; - font-size: 15px; - line-height: 1.3; - margin: 0; + background-color: var(--bg_color); + color: var(--fg_color); + font-family: $font_0, $font_1, $font_2, $font_3; + font-size: 14px; + line-height: 1.3; + margin: 0; } * { - outline: unset; - margin: 0; - text-decoration: none; -} - -img { - dynamic-range-limit: standard; + outline: unset; + margin: 0; + text-decoration: none; } h1 { - display: inline; + display: inline; } -h2, -h3 { - font-weight: normal; +h2, h3 { + font-weight: normal; } p { - margin: 14px 0; + margin: 14px 0; } a { - color: var(--accent); + color: var(--accent); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; + } } fieldset { - border: 0; - padding: 0; - margin-top: -0.6em; + border: 0; + padding: 0; + margin-top: -0.6em; } legend { - width: 100%; - padding: 0.6em 0 0.3em 0; - border: 0; - font-size: 16px; - font-weight: 600; - border-bottom: 1px solid var(--border_grey); - margin-bottom: 8px; + width: 100%; + padding: .6em 0 .3em 0; + border: 0; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border_grey); + margin-bottom: 8px; } -.preferences { - .note { +.preferences .note { border-top: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey); padding: 6px 0 8px 0; margin-bottom: 8px; margin-top: 16px; - } - - .bookmark-note { - margin: 0; - margin-bottom: 10px; - } } ul { - padding-left: 1.3em; + padding-left: 1.3em; } .container { - display: flex; - flex-wrap: wrap; - box-sizing: border-box; - margin: auto; - min-height: 100vh; -} - -body.fixed-nav .container { - padding-top: 50px; + display: flex; + flex-wrap: wrap; + box-sizing: border-box; + padding-top: 50px; + margin: auto; + min-height: 100vh; } .icon-container { - display: inline; + display: inline; } .overlay-panel { - max-width: 600px; - width: 100%; - margin: 0 auto; - margin-top: 10px; - background-color: var(--bg_overlays); - padding: 10px 15px; - align-self: start; + max-width: 600px; + width: 100%; + margin: 0 auto; + margin-top: 10px; + background-color: var(--bg_overlays); + padding: 10px 15px; + align-self: start; - ul { - margin-bottom: 14px; - } + ul { + margin-bottom: 14px; + } - p { - word-break: break-word; - } + p { + word-break: break-word; + } } .verified-icon { - display: inline-block; - position: relative; - width: 14px; - height: 14px; - margin-bottom: 2px; + color: var(--icon_text); + border-radius: 50%; + flex-shrink: 0; + margin: 2px 0 3px 3px; + padding-top: 3px; + height: 11px; + width: 14px; + font-size: 8px; + display: inline-block; + text-align: center; + vertical-align: middle; - .verified-icon-circle { - position: absolute; - font-size: 15px; - } - - .verified-icon-check { - position: absolute; - font-size: 9px; - margin: 5px 3px; - } - - &.blue { - .verified-icon-circle { - color: var(--verified_blue); + &.blue { + background-color: var(--verified_blue); } - .verified-icon-check { - color: var(--icon_text); - } - } - - &.business { - .verified-icon-circle { - color: var(--verified_business); + &.business { + color: var(--bg_panel); + background-color: var(--verified_business); } - .verified-icon-check { - color: var(--bg_panel); + &.government { + color: var(--bg_panel); + background-color: var(--verified_government); } - } - - &.government { - .verified-icon-circle { - color: var(--verified_government); - } - - .verified-icon-check { - color: var(--bg_panel); - } - } } -@media (max-width: 600px) { - .preferences-container { - max-width: 95vw; - } +@media(max-width: 600px) { + .preferences-container { + max-width: 95vw; + } - .nav-item, - .nav-item .icon-container { - font-size: 16px; - } + .nav-item, .nav-item .icon-container { + font-size: 16px; + } } diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 2b6016f..17c2a22 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -1,216 +1,185 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; button { - @include input-colors; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 3px 6px; - font-size: 14px; - cursor: pointer; - float: right; + @include input-colors; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 3px 6px; + font-size: 14px; + cursor: pointer; + float: right; } input[type="text"], input[type="date"], -input[type="number"], select { - @include input-colors; - background-color: var(--bg_elements); - padding: 1px 4px; - color: var(--fg_color); - border: 1px solid var(--accent_border); - border-radius: 0; - font-size: 14px; + @include input-colors; + background-color: var(--bg_elements); + padding: 1px 4px; + color: var(--fg_color); + border: 1px solid var(--accent_border); + border-radius: 0; + font-size: 14px; } -input[type="number"] { - -moz-appearance: textfield; -} - -input[type="text"], -input[type="number"] { - height: 16px; +input[type="text"] { + height: 16px; } select { - height: 20px; - padding: 0 2px; - line-height: 1; + height: 20px; + padding: 0 2px; + line-height: 1; } input[type="date"]::-webkit-inner-spin-button { - display: none; -} - -input[type="number"] { - -moz-appearance: textfield; -} - -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { - display: none; - -webkit-appearance: none; - margin: 0; + display: none; } input[type="date"]::-webkit-clear-button { - margin-left: 17px; - filter: grayscale(100%); - filter: hue-rotate(120deg); + margin-left: 17px; + filter: grayscale(100%); + filter: hue-rotate(120deg); } input::-webkit-calendar-picker-indicator { - opacity: 0; + opacity: 0; } input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-year-field:focus { - background-color: var(--accent); - color: var(--fg_color); - outline: none; + background-color: var(--accent); + color: var(--fg_color); + outline: none; } .date-range { - .date-input { - display: inline-block; - position: relative; - } + .date-input { + display: inline-block; + position: relative; + } - .icon-container { - pointer-events: none; - position: absolute; - top: 2px; - right: 5px; - } + .icon-container { + pointer-events: none; + position: absolute; + top: 2px; + right: 5px; + } - .search-title { - margin: 0 2px; - } + .search-title { + margin: 0 2px; + } } .icon-button button { - color: var(--accent); - text-decoration: none; - background: none; - border: none; - float: none; - padding: unset; - padding-left: 4px; + color: var(--accent); + text-decoration: none; + background: none; + border: none; + float: none; + padding: unset; + padding-left: 4px; - &:hover { - color: var(--accent_light); - } + &:hover { + color: var(--accent_light); + } } .checkbox { - position: absolute; - top: 1px; - right: 0; - height: 17px; - width: 17px; - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); - - &:after { - content: ""; position: absolute; - display: none; - } + top: 1px; + right: 0; + height: 17px; + width: 17px; + background-color: var(--bg_elements); + border: 1px solid var(--accent_border); + + &:after { + content: ""; + position: absolute; + display: none; + } } .checkbox-container { - display: block; - position: relative; - margin-bottom: 5px; - cursor: pointer; - user-select: none; - padding-right: 22px; - - input { - position: absolute; - opacity: 0; + display: block; + position: relative; + margin-bottom: 5px; cursor: pointer; - height: 0; - width: 0; + user-select: none; + padding-right: 22px; - &:checked ~ .checkbox:after { - display: block; + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + + &:checked ~ .checkbox:after { + display: block; + } } - } - &:hover input ~ .checkbox { - border-color: var(--accent); - } + &:hover input ~ .checkbox { + border-color: var(--accent); + } - &:active input ~ .checkbox { - border-color: var(--accent_light); - } + &:active input ~ .checkbox { + border-color: var(--accent_light); + } - .checkbox:after { - left: 2px; - bottom: 0; - font-size: 13px; - font-family: $font_1; - content: "\e811"; - } + .checkbox:after { + left: 2px; + bottom: 0; + font-size: 13px; + font-family: $font_4; + content: '\e803'; + } } .pref-group { - display: inline; + display: inline; } .preferences { - button { - margin: 6px 0 3px 0; - } + button { + margin: 6px 0 3px 0; + } - label { - padding-right: 150px; - } + label { + padding-right: 150px; + } - select { - position: absolute; - top: 0; - right: 0; - display: block; - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - min-width: 100px; - } + select { + position: absolute; + top: 0; + right: 0; + display: block; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + } - input[type="text"], - input[type="number"] { - position: absolute; - right: 0; - max-width: 140px; - } + input[type="text"] { + position: absolute; + right: 0; + max-width: 140px; + } - .pref-group { - display: block; - } + .pref-group { + display: block; + } - .pref-input { - position: relative; - margin-bottom: 6px; - } + .pref-input { + position: relative; + margin-bottom: 6px; + } - .pref-reset { - float: left; - } - - .prefs-code { - background-color: var(--bg_elements); - border: 1px solid var(--accent_border); - color: var(--fg_color); - font-size: 13px; - padding: 6px 8px; - margin: 4px 0; - word-break: break-all; - white-space: pre-wrap; - user-select: all; - } + .pref-reset { + float: left; + } } diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss index c999022..47a8765 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -1,90 +1,89 @@ -@import "_variables"; +@import '_variables'; nav { - display: flex; - align-items: center; - background-color: var(--bg_overlays); - box-shadow: 0 0 4px $shadow; - padding: 0; - width: 100%; - height: 50px; - z-index: 1000; - font-size: 16px; - - a, - .icon-button button { - color: var(--fg_nav); - } - - body.fixed-nav & { + display: flex; + align-items: center; position: fixed; - } + background-color: var(--bg_overlays); + box-shadow: 0 0 4px $shadow; + padding: 0; + width: 100%; + height: 50px; + z-index: 1000; + font-size: 16px; + + a, .icon-button button { + color: var(--fg_nav); + } } .inner-nav { - margin: auto; - box-sizing: border-box; - padding: 0 10px; - display: flex; - align-items: center; - flex-basis: 920px; - height: 50px; + margin: auto; + box-sizing: border-box; + padding: 0 10px; + display: flex; + align-items: center; + flex-basis: 920px; + height: 50px; } .site-name { - font-size: 15px; - font-weight: 600; - line-height: 1; + font-size: 15px; + font-weight: 600; + line-height: 1; - &:hover { - color: var(--accent_light); - text-decoration: unset; - } + &:hover { + color: var(--accent_light); + text-decoration: unset; + } } .site-logo { - display: block; - width: 35px; - height: 35px; + display: block; + width: 35px; + height: 35px; } .nav-item { - display: flex; - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - flex-wrap: wrap; - align-items: center; + display: flex; + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + flex-wrap: wrap; + align-items: center; - &.right { - text-align: right; - justify-content: flex-end; - } + &.right { + text-align: right; + justify-content: flex-end; + } - &.right a:hover { - color: var(--accent_light); - text-decoration: unset; - } + &.right a { + padding-left: 4px; + + &:hover { + color: var(--accent_light); + text-decoration: unset; + } + } } .lp { - height: 14px; - display: inline-block; - position: relative; - top: 2px; - fill: var(--fg_nav); + height: 14px; + display: inline-block; + position: relative; + top: 2px; + fill: var(--fg_nav); - &:hover { - fill: var(--accent_light); - } + &:hover { + fill: var(--accent_light); + } } -.icon-info { - margin: 0 -3px; +.icon-info:before { + margin: 0 -3px; } .icon-cog { - font-size: 15px; - padding-left: 0 !important; + font-size: 15px; } diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 81b3d78..b7f33e6 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -1,118 +1,83 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; -@import "card"; -@import "about-account"; -@import "photo-rail"; -@import "community"; +@import 'card'; +@import 'photo-rail'; .profile-tabs { - @include panel(auto, 900px); + @include panel(auto, 900px); - .timeline-container { - float: right; - width: 68% !important; - max-width: unset; - } + .timeline-container { + float: right; + width: 68% !important; + max-width: unset; + } } .profile-banner { - margin-bottom: 4px; - background-color: var(--bg_panel); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 0; - } + img { + max-width: 100%; + position: absolute; + top: 0; + } } .profile-tab { - padding: 0 4px 0 0; - box-sizing: border-box; - display: inline-block; - font-size: 14px; - text-align: left; - vertical-align: top; - max-width: 32%; - top: 0; - - body.fixed-nav & { + padding: 0 4px 0 0; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + text-align: left; + vertical-align: top; + max-width: 32%; top: 50px; - } } .profile-result { - min-height: 54px; + min-height: 54px; - .username { - margin: 0 !important; - } + .username { + margin: 0 !important; + } - .tweet-header { - margin-bottom: unset; - } + .tweet-header { + margin-bottom: unset; + } } -.profile-tabs.media-only { - max-width: none; - width: 100%; +@media(max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; - .timeline-container { - float: none; - width: 100% !important; - max-width: none; - padding: 0 10px; - box-sizing: border-box; - } + .timeline-container { + width: 100% !important; - .timeline-container > .tab { - max-width: 900px; - margin-left: auto; - margin-right: auto; - } -} - -@media (max-width: 700px) { - .profile-tabs { - width: 100vw; - max-width: 600px; - - .timeline-container { - width: 100% !important; - - .tab-item wide { - flex-grow: 1.4; - } + .tab-item wide { + flex-grow: 1.4; + } + } } - } - .profile-tabs.media-only { - width: 100%; - max-width: none; - - .timeline-container { - width: 100vw !important; - padding: 0; + .profile-tab { + width: 100%; + max-width: unset; + position: initial !important; + padding: 0; } - } - - .profile-tab { - width: 100%; - max-width: unset; - position: initial !important; - padding: 0; - } } @media (min-height: 900px) { - .profile-tab.sticky { - position: sticky; - } + .profile-tab.sticky { + position: sticky; + } } diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss deleted file mode 100644 index 92d13c9..0000000 --- a/src/sass/profile/_community.scss +++ /dev/null @@ -1,203 +0,0 @@ -.community-header { - padding: 12px 15px; - border-bottom: 1px solid var(--border_grey); - background-color: var(--bg_panel); - - .community-name { - font-size: 22px; - margin-bottom: 6px; - - a { - color: inherit; - } - } - - .community-category { - display: inline-block; - background-color: var(--bg_elements); - border: 1px solid var(--border_grey); - border-radius: 16px; - padding: 2px 12px; - font-size: 13px; - color: var(--fg_faded); - margin-bottom: 8px; - } - - .community-description { - color: var(--fg_faded); - margin-bottom: 8px; - line-height: 1.4; - } - - .community-member-count { - font-weight: bold; - color: inherit; - } - - .community-stats { - color: var(--grey); - font-size: 14px; - } -} - -.community-about { - padding: 16px 15px 15px; - background-color: var(--bg_panel); - - h2 { - font-size: 18px; - margin: 0 0 12px; - } - - .community-info { - border-bottom: 1px solid var(--border_grey); - padding-bottom: 12px; - } - - .community-info-item { - display: flex; - gap: 10px; - padding: 8px 0; - align-items: center; - - .verified-icon { - margin-left: 2px; - } - - > .icon-container { - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - color: var(--grey); - flex-shrink: 0; - width: 26px; - height: 26px; - } - - strong { - color: var(--fg_color); - } - - a { - color: var(--accent); - } - } - - .community-rules { - border-bottom: 1px solid var(--border_grey); - padding: 16px 0 12px; - } - - .community-rules-intro { - color: var(--fg_faded); - font-size: 14px; - margin: 0 0 12px; - } - - .community-rule { - display: flex; - gap: 10px; - padding: 10px 0; - align-items: flex-start; - - .community-rule-number { - display: flex; - align-items: center; - justify-content: center; - min-width: 26px; - height: 26px; - border-radius: 50%; - background-color: var(--accent); - color: var(--fg_color); - font-weight: bold; - font-size: 13px; - flex-shrink: 0; - } - - .community-rule-content p { - margin: 4px 0 0; - color: var(--fg_faded); - font-size: 14px; - } - } - - .community-moderators { - padding-top: 16px; - - h2 { - display: flex; - align-items: center; - justify-content: space-between; - } - - .community-mods-link { - font-size: 14px; - font-weight: normal; - color: var(--accent); - } - } - - .community-moderator { - display: flex; - gap: 10px; - padding: 8px 0; - align-items: center; - - .community-mod-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - } - - .community-mod-info { - display: flex; - flex-direction: column; - } - - .community-mod-name { - display: flex; - align-items: center; - font-weight: bold; - color: var(--fg_color); - - .verified-icon { - margin-left: 2px; - } - } - - .community-mod-username { - color: var(--fg_faded); - font-size: 14px; - } - } -} - -.community-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 10px 15px; - border-bottom: 1px solid var(--border_grey); - - .community-tag { - display: inline-block; - background-color: var(--bg_elements); - border: 1px solid var(--border_grey); - border-radius: 16px; - padding: 4px 12px; - font-size: 13px; - color: var(--accent); - } -} - -.community-hashtag-header { - padding: 12px 15px; - border-bottom: 1px solid var(--border_grey); - - .community-hashtag-title { - font-size: 20px; - color: var(--accent); - margin: 0; - } -} diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss deleted file mode 100644 index aa12f49..0000000 --- a/src/sass/profile/about-account.scss +++ /dev/null @@ -1,71 +0,0 @@ -@import '_variables'; - -.about-account { - max-width: 500px; - width: 100%; - margin: 20px auto 0; - align-self: flex-start; - background: var(--bg_panel); - border-radius: 4px; - padding: 12px 20px 20px; -} - -.about-account-header { - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid var(--border_grey); -} - -.about-account-avatar img { - width: 72px; - height: 72px; - border-radius: 50%; - margin-bottom: 4px; -} - -.about-account-name { - @include breakable; - font-weight: bold; -} - -.about-account-body { - display: flex; - flex-direction: column; - gap: 14px; -} - -.about-account-at { - font-size: 18px; - font-weight: bold; -} - -.about-account-row { - display: flex; - align-items: center; - gap: 10px; - - > span:first-child { - color: var(--fg_faded); - flex-shrink: 0; - } - - > div { - display: flex; - flex-direction: column; - } -} - -.about-account-label { - color: var(--fg_faded); - font-size: 13px; -} - -@media(max-width: 700px) { - .about-account { - max-width: none; - margin: 10px; - } -} diff --git a/src/sass/search.scss b/src/sass/search.scss index fa2a2d8..f70f7ea 100644 --- a/src/sass/search.scss +++ b/src/sass/search.scss @@ -1,194 +1,122 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; .search-title { - font-weight: bold; - display: inline-block; - margin-top: 4px; + font-weight: bold; + display: inline-block; + margin-top: 4px; } .search-field { - display: flex; - flex-wrap: wrap; - - button { - margin: 0 2px 0 0; - padding: 0px 1px 1px 4px; - height: 23px; display: flex; - align-items: center; - } + flex-wrap: wrap; - .pref-input { - margin: 0 4px 0 0; - flex-grow: 1; - height: 23px; - } + button { + margin: 0 2px 0 0; + height: 23px; + display: flex; + align-items: center; + } - input[type="text"], - input[type="number"] { - height: calc(100% - 4px); - width: calc(100% - 8px); - } + .pref-input { + margin: 0 4px 0 0; + flex-grow: 1; + height: 23px; + } - > label { - display: inline; - background-color: var(--bg_elements); - color: var(--fg_color); - border: 1px solid var(--accent_border); - padding: 1px 1px 2px 4px; - font-size: 14px; - cursor: pointer; - margin-bottom: 2px; + input[type="text"] { + height: calc(100% - 4px); + width: calc(100% - 8px); + } - @include input-colors; - } + > label { + display: inline; + background-color: var(--bg_elements); + color: var(--fg_color); + border: 1px solid var(--accent_border); + padding: 1px 6px 2px 6px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; - @include create-toggle(search-panel, 380px); + @include input-colors; + } + + @include create-toggle(search-panel, 200px); } .search-panel { - width: 100%; - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; + width: 100%; + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; - flex-grow: 1; - font-weight: initial; - text-align: left; + flex-grow: 1; + font-weight: initial; + text-align: left; - .checkbox-container { - display: inline; - padding-right: unset; - margin-bottom: 5px; - margin-left: 23px; - } + > div { + line-height: 1.7em; + } - .checkbox { - right: unset; - left: -22px; - line-height: 1.6em; - } + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: unset; + margin-left: 23px; + } - .checkbox-container .checkbox:after { - top: -4px; - } + .checkbox { + right: unset; + left: -22px; + } + + .checkbox-container .checkbox:after { + top: -4px; + } } .search-row { - display: flex; - flex-wrap: wrap; - line-height: unset; + display: flex; + flex-wrap: wrap; + line-height: unset; - > div { - flex-grow: 1; - flex-shrink: 1; - } - - input { - height: 21px; - } - - .pref-input { - display: block; - padding-bottom: 5px; + > div { + flex-grow: 1; + flex-shrink: 1; + } input { - height: 21px; - margin-top: 1px; + height: 21px; + } + + .pref-input { + display: block; + padding-bottom: 5px; + + input { + height: 21px; + margin-top: 1px; + } } - } } .search-toggles { - flex-grow: 1; - display: grid; - grid-template-columns: repeat(5, auto); - grid-column-gap: 10px; -} - -.list-result { - display: flex; - align-items: flex-start; - - .list-result-banner { - flex-shrink: 0; - width: 56px; - height: 56px; - margin-right: 10px; - border-radius: 8px; - overflow: hidden; - background-color: var(--darker_grey); - // stay above the tweet-link overlay's hover background - z-index: 1; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - .list-result-body { - min-width: 0; - pointer-events: none; - z-index: 1; - - a { - pointer-events: all; - } - } - - .list-result-title { - align-items: baseline; - } - - .list-members { - flex-shrink: 0; - margin-left: 0.3em; - color: var(--fg_faded); - } - - .list-result-context { - display: flex; - align-items: center; - flex-wrap: wrap; - margin-top: 2px; - color: var(--fg_faded); - - a { - color: var(--fg_dark); - } - - a.fullname { - color: var(--fg_color); - } - - .list-facepile { - width: 20px; - height: 20px; - border-radius: 50%; - margin-right: 4px; - } - } - - .list-result-description { - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: all; - } + flex-grow: 1; + display: grid; + grid-template-columns: repeat(6, auto); + grid-column-gap: 10px; } .profile-tabs { - @include search-resize(820px, 5); - @include search-resize(715px, 4); - @include search-resize(700px, 5); - @include search-resize(485px, 4); - @include search-resize(410px, 3); + @include search-resize(820px, 5); + @include search-resize(725px, 4); + @include search-resize(600px, 6); + @include search-resize(560px, 5); + @include search-resize(480px, 4); + @include search-resize(410px, 3); } -@include search-resize(700px, 5); -@include search-resize(485px, 4); +@include search-resize(560px, 5); +@include search-resize(480px, 4); @include search-resize(410px, 3); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index b7d4a9f..c8ce309 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -1,505 +1,162 @@ -@import "_variables"; +@import '_variables'; .timeline-container { - @include panel(100%, 600px); + @include panel(100%, 600px); } -.timeline-container.media-only { - max-width: none; - width: 100%; - padding: 0 10px; - box-sizing: border-box; +.timeline { + background-color: var(--bg_panel); - > .tab, - > .timeline-header { - max-width: 900px; - margin-left: auto; - margin-right: auto; - } -} - -@media (max-width: 700px) { - .timeline-container.media-only { - padding: 0; - } -} - -.timeline > div:not(:first-child) { - border-top: 1px solid var(--border_grey); + > div:not(:first-child) { + border-top: 1px solid var(--border_grey); + } } .timeline-header { - width: 100%; - background-color: var(--bg_panel); - text-align: center; - padding: 8px; - display: block; - font-weight: bold; - margin-bottom: 4px; - box-sizing: border-box; + width: 100%; + background-color: var(--bg_panel); + text-align: center; + padding: 8px; + display: block; + font-weight: bold; + margin-bottom: 5px; + box-sizing: border-box; - button { - float: unset; - } + button { + float: unset; + } } .timeline-banner img { - width: 100%; + width: 100%; } .timeline-description { - font-weight: normal; + font-weight: normal; } .tab { - align-items: center; - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 4px 0; - background-color: var(--bg_panel); - padding: 0; + align-items: center; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 5px 0; + background-color: var(--bg_panel); + padding: 0; } .tab-item { - flex: 1 1 0; - text-align: center; - margin-top: 0; + flex: 1 1 0; + text-align: center; + margin-top: 0; - a { - border-bottom: 0.1rem solid transparent; - color: var(--tab); - display: block; - padding: 8px 0; - text-decoration: none; - font-weight: bold; + a { + border-bottom: .1rem solid transparent; + color: var(--tab); + display: block; + padding: 8px 0; + text-decoration: none; + font-weight: bold; - &:hover { - text-decoration: none; + &:hover { + text-decoration: none; + } + + &.active { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); + } } - &.active { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); + &.active a { + border-bottom-color: var(--tab_selected); + color: var(--tab_selected); } - } - &.active a { - border-bottom-color: var(--tab_selected); - color: var(--tab_selected); - } - - &.wide { - flex-grow: 1.2; - flex-basis: 50px; - } + &.wide { + flex-grow: 1.2; + flex-basis: 50px; + } } .timeline-footer { - background-color: var(--bg_panel); - padding: 6px 0; + background-color: var(--bg_panel); + padding: 6px 0; } .timeline-protected { - text-align: center; + text-align: center; - p { - margin: 8px 0; - } + p { + margin: 8px 0; + } - h2 { - color: var(--accent); - font-size: 20px; - font-weight: 600; - } + h2 { + color: var(--accent); + font-size: 20px; + font-weight: 600; + } } .timeline-none { - color: var(--accent); - font-size: 20px; - font-weight: 600; - text-align: center; + color: var(--accent); + font-size: 20px; + font-weight: 600; + text-align: center; } .timeline-end { - background-color: var(--bg_panel); - color: var(--accent); - font-size: 16px; - font-weight: 600; - text-align: center; + background-color: var(--bg_panel); + color: var(--accent); + font-size: 16px; + font-weight: 600; + text-align: center; } .show-more { - background-color: var(--bg_panel); - text-align: center; - padding: 0.75em 0; - display: block !important; + background-color: var(--bg_panel); + text-align: center; + padding: .75em 0; + display: block !important; - a { - background-color: var(--darkest_grey); - display: inline-block; - height: 2em; - padding: 0 2em; - line-height: 2em; + a { + background-color: var(--darkest_grey); + display: inline-block; + height: 2em; + padding: 0 2em; + line-height: 2em; - &:hover { - background-color: var(--darker_grey); + &:hover { + background-color: var(--darker_grey); + } } - } } .top-ref { - background-color: var(--bg_color); - border-top: none !important; + background-color: var(--bg_color); + border-top: none !important; - .icon-down { - font-size: 20px; - display: flex; - justify-content: center; - text-decoration: none; + .icon-down { + font-size: 20px; + display: flex; + justify-content: center; + text-decoration: none; - &:hover { - color: var(--accent_light); + &:hover { + color: var(--accent_light); + } + + &::before { + transform: rotate(180deg) translateY(-1px); + } } - - &::before { - transform: rotate(180deg) translateY(-1px); - } - } } .timeline-item { - overflow-wrap: break-word; - border-left-width: 0; - min-width: 0; - padding: 0.75em; - display: flex; - position: relative; - background-color: var(--bg_panel); -} - -.timeline.media-grid-view, -.timeline.media-gallery-view { - > div:not(:first-child) { - border-top: none; - } - - .timeline-item::before { - display: none; - } -} - -.timeline.media-grid-view, -.timeline.media-gallery-view .gallery-masonry.compact { - .tweet-header, - .replying-to, - .retweet-header, - .pinned, - .tweet-stats, - .attribution, - .poll, - .quote, - .community-note, - .media-tag-block, - .tweet-content, - .card-content { - display: none; - } - - .card { - margin: unset; - - .card-container { - border: unset; - border-radius: unset; - - .card-image-container { - width: 100%; - min-height: 100%; - } - - .card-content-container { - display: none; - } - } - } -} - -.timeline.media-grid-view { - display: grid; - gap: 4px; - grid-template-columns: repeat(3, minmax(0, 1fr)); - - > div:not(:first-child) { - margin-top: 0; - } - - .timeline-item { - padding: 0; - } - - .tweet-link { - z-index: 1000; - - &:hover { - background-color: unset; - } - } - - > .show-more, - > .top-ref, - > .timeline-footer, - > .timeline-header { - grid-column: 1 / -1; - } - - .tweet-body { - height: 100%; - margin-left: 0; - padding: 0; + overflow-wrap: break-word; + border-left-width: 0; + min-width: 0; + padding: .75em; + display: flex; position: relative; - aspect-ratio: 1/1; - } - - .gallery-row + .gallery-row { - margin-top: 0.25em !important; - } - - .attachments { - background-color: var(--darkest_grey); - border-radius: 0; - margin: 0; - max-height: none; - } - - .attachments, - .gallery-row, - .still-image { - height: 100%; - width: 100%; - } - - .still-image img, - .attachment > video, - .attachment > img { - object-fit: cover; - height: 100%; - width: 100%; - } - - .attachment { - display: flex; - align-items: center; - } - - .gallery-video { - height: 100%; - } - - .media-gif { - display: flex; - } - - .timeline-item:hover { - opacity: 0.85; - } - - .alt-text { - display: none; - } -} - -.timeline.media-gallery-view { - .gallery-masonry { - margin: 10px 0; - column-gap: 10px; - column-width: unquote("clamp(190px, 22vw, 350px)"); - - &[data-col-size="small"] { - column-width: unquote("max(130px, 11vw)"); - } - - &[data-col-size="large"] { - column-width: unquote("clamp(350px, 22vw, 480px)"); - } - - &.masonry-active { - column-width: unset; - column-gap: unset; - position: relative; - - .timeline-item { - animation: none; - position: absolute; - box-sizing: border-box; - margin-bottom: 0; - } - } - - &.compact { - .tweet-body { - padding: 0; - - > .attachments { - margin: 0; - } - } - - .card-image-container img { - max-height: unset; - } - } - } - - @keyframes masonry-init { - to { - opacity: 1; - pointer-events: auto; - } - } - - // Start hidden. CSS animation reveals after a delay as a no-JS fallback. - // With JS, masonry-active cancels the animation and masonry-visible reveals. - .gallery-masonry .timeline-item, - > .show-more, - > .top-ref, - > .timeline-footer { - opacity: 0; - pointer-events: none; - animation: masonry-init 0.2s 0.3s forwards; - } - - .gallery-masonry.masonry-active .timeline-item.masonry-visible, - > .show-more.masonry-visible, - > .top-ref.masonry-visible, - > .timeline-footer.masonry-visible { - opacity: 1; - pointer-events: auto; - transition: opacity 0.15s ease; - animation: none; - } - - .timeline-item { - margin-bottom: 10px; - break-inside: avoid; - flex-direction: column; - padding: 0; - } - - > .show-more, - > .top-ref, - > .timeline-footer, - > .timeline-header { - margin-left: auto; - margin-right: auto; - max-width: 900px; - } - - > .show-more { - padding: 0; - margin-top: 8px; - background-color: unset; - } - - .tweet-content { - margin: 3px 0; - } - - .tweet-body { - display: flex; - flex-direction: column; - height: 100%; - margin-left: 0; - padding: 10px; - - > .attachments { - align-self: stretch; - border-radius: 0; - margin: -10px -10px 10px; - max-height: none; - order: -1; - width: auto; - background-color: var(--bg_elements); - - .gallery-row { - max-height: none; - max-width: none; - align-items: center; - } - - .still-image img, - .attachment > video, - .attachment > img { - max-height: none; - width: 100%; - } - - .attachment:last-child { - max-height: none; - } - - .card-container { - border: unset; - border-radius: unset; - } - } - - .tweet-stat { - padding-top: unset; - } - - .quote { - margin-bottom: 5px; - margin-top: 5px; - } - - .replying-to { - margin: 0; - } - } - - .tweet-header { - align-items: flex-start; - display: flex; - gap: 0.75em; - margin-bottom: 0; - - .tweet-avatar { - img { - float: none; - height: 42px; - margin: 0; - width: 42px; - } - } - - .tweet-name-row { - flex: 1; - } - - .fullname-and-username { - flex-wrap: wrap; - } - - .fullname { - max-width: calc(100% - 18px); - } - - .verified-icon { - margin-left: 4px; - margin-top: 1px; - } - - .username { - display: block; - flex-basis: 100%; - margin-left: 0; - } - } -} - -@media (max-width: 520px) { - .timeline.media-gallery-view { - padding: 8px 0; - } } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 2f6693e..69f51c0 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -1,284 +1,240 @@ -@import "_variables"; -@import "_mixins"; -@import "thread"; -@import "media"; -@import "video"; -@import "embed"; -@import "card"; -@import "poll"; -@import "quote"; +@import '_variables'; +@import '_mixins'; +@import 'thread'; +@import 'media'; +@import 'video'; +@import 'embed'; +@import 'card'; +@import 'poll'; +@import 'quote'; .tweet-body { - flex: 1; - min-width: 0; - margin-left: 58px; - pointer-events: none; - z-index: 1; + flex: 1; + min-width: 0; + margin-left: 58px; + pointer-events: none; + z-index: 1; } .tweet-content { - line-height: 1.3em; - pointer-events: all; - display: inline; + font-family: $font_3; + line-height: 1.3em; + pointer-events: all; + display: inline; } .tweet-bidi { - display: block !important; + display: block !important; } .tweet-header { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: 0.2em; + padding: 0; + vertical-align: bottom; + flex-basis: 100%; + margin-bottom: .2em; - a { - display: inline-block; - word-break: break-all; - max-width: 100%; - pointer-events: all; - } + a { + display: inline-block; + word-break: break-all; + max-width: 100%; + pointer-events: all; + } } .tweet-name-row { - padding: 0; - display: flex; - justify-content: space-between; - - .verified-icon { - margin-left: 2px; - } + padding: 0; + display: flex; + justify-content: space-between; } .fullname-and-username { - display: flex; - min-width: 0; + display: flex; + min-width: 0; } .fullname { - @include ellipsis; - flex-shrink: 2; - max-width: 80%; - font-size: 14px; - font-weight: 700; - color: var(--fg_color); + @include ellipsis; + flex-shrink: 2; + max-width: 80%; + font-size: 14px; + font-weight: 700; + color: var(--fg_color); } .username { - @include ellipsis; - min-width: 1.6em; - margin-left: 0.4em; - word-wrap: normal; + @include ellipsis; + min-width: 1.6em; + margin-left: .4em; + word-wrap: normal; } .tweet-date { - display: flex; - flex-shrink: 0; - margin-left: 4px; + display: flex; + flex-shrink: 0; + margin-left: 4px; } -.tweet-date a, -.username, -.show-more a { - color: var(--fg_dark); +.tweet-date a, .username, .show-more a { + color: var(--fg_dark); } .tweet-published { - margin-top: 6px; - margin-bottom: 0px; - color: var(--grey); + margin: 0; + margin-top: 5px; + color: var(--grey); + pointer-events: all; } .tweet-avatar { - display: contents !important; + display: contents !important; - img { - float: left; - margin-top: 3px; - margin-left: -58px; - width: 48px; - height: 48px; - } + img { + float: left; + margin-top: 3px; + margin-left: -58px; + width: 48px; + height: 48px; + } } .avatar { - &.round { - border-radius: 50%; - user-select: none; - -webkit-user-select: none; - } + &.round { + border-radius: 50%; + -webkit-user-select: none; + } + + &.mini { + position: unset; + margin-right: 5px; + margin-top: -1px; + width: 20px; + height: 20px; + } +} - &.mini { - position: unset; - margin-right: 5px; - margin-top: -1px; - width: 20px; - height: 20px; - } +.tweet-embed { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + background-color: var(--bg_panel); + + .tweet-content { + font-size: 18px; + } + + .tweet-body { + display: flex; + flex-direction: column; + max-height: calc(100vh - 0.75em * 2); + } + + .card-image img { + height: auto; + } + + .avatar { + position: absolute; + } } .attribution { - display: flex; - pointer-events: all; - margin: 5px 0; + display: flex; + pointer-events: all; + margin: 5px 0; - strong { - color: var(--fg_color); - } + strong { + color: var(--fg_color); + } } .media-tag-block { - padding-top: 5px; - pointer-events: all; - color: var(--fg_faded); - - .icon-container { - padding-right: 2px; - } - - .media-tag, - .icon-container { + padding-top: 5px; + pointer-events: all; color: var(--fg_faded); - } + + .icon-container { + padding-right: 2px; + } + + .media-tag, .icon-container { + color: var(--fg_faded); + } } .timeline-container .media-tag-block { - font-size: 13px; + font-size: 13px; } .tweet-geo { - color: var(--fg_faded); + color: var(--fg_faded); } .replying-to { - color: var(--fg_faded); - margin: -2px 0 4px; + color: var(--fg_faded); + margin: -2px 0 4px; - a { - pointer-events: all; - } + a { + pointer-events: all; + } } -.retweet-header, -.pinned, -.tweet-stats { - align-content: center; - color: var(--grey); - display: flex; - flex-shrink: 0; - flex-wrap: wrap; - font-size: 14px; - font-weight: 600; - line-height: 22px; +.retweet-header, .pinned, .tweet-stats { + align-content: center; + color: var(--grey); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + line-height: 22px; - span { - @include ellipsis; - } + span { + @include ellipsis; + } } .retweet-header { - margin-top: -5px !important; + margin-top: -5px !important; } .tweet-stats { - margin-bottom: -3px; - user-select: none; - -webkit-user-select: none; + margin-bottom: -3px; + -webkit-user-select: none; } .tweet-stat { - padding-top: 5px; - min-width: 1em; - margin-right: 0.8em; + padding-top: 5px; + min-width: 1em; + margin-right: 0.8em; } .show-thread { - display: block; - pointer-events: all; - padding-top: 2px; + display: block; + pointer-events: all; + padding-top: 2px; } .unavailable-box { - width: 100%; - height: 100%; - padding: 12px; - border: solid 1px var(--dark_grey); - box-sizing: border-box; - border-radius: 10px; - background-color: var(--bg_color); - z-index: 2; + width: 100%; + height: 100%; + padding: 12px; + border: solid 1px var(--dark_grey); + box-sizing: border-box; + border-radius: 10px; + background-color: var(--bg_color); + z-index: 2; } .tweet-link { - height: 100%; - width: 100%; - left: 0; - top: 0; - position: absolute; - user-select: none; - -webkit-user-select: none; + height: 100%; + width: 100%; + left: 0; + top: 0; + position: absolute; + -webkit-user-select: none; - &:hover { - background-color: var(--bg_hover); - } -} - -.latest-post-version { - border-bottom: 1px solid var(--dark_grey); - border-top: 1px solid var(--dark_grey); - padding: 01ch 0px; - margin: 1ch 0px; - color: var(--grey); - - a { - pointer-events: all; - } -} - -.community-note { - background-color: var(--bg_elements); - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - overflow: hidden; - pointer-events: all; - - &:hover { - background-color: var(--bg_panel); - border-color: var(--grey); - } -} - -.community-note-header { - background-color: var(--bg_hover); - font-weight: 700; - padding: 8px 10px; - padding-top: 6px; - display: flex; - align-items: center; - gap: 2px; - - .icon-container { - flex-shrink: 0; - color: var(--accent); - } -} - -.community-note-text { - white-space: pre-line; - padding: 10px 10px; - padding-top: 6px; -} - -.disclosures { - display: flex; - flex-direction: column; - color: var(--grey); - font-size: 14px; - margin-top: 4px; - margin-bottom: -2px; - - .icon-attention-circled { - margin-right: -3px; - } + &:hover { + background-color: var(--bg_hover); + } } diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 7441d11..5575191 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -1,119 +1,119 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; .card { - margin: 5px 0; - pointer-events: all; - max-height: unset; + margin: 5px 0; + pointer-events: all; + max-height: unset; } .card-container { - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); - overflow: hidden; - color: inherit; - display: flex; - flex-direction: row; - text-decoration: none !important; + border-radius: 10px; + border-width: 1px; + border-style: solid; + border-color: var(--dark_grey); + background-color: var(--bg_elements); + overflow: hidden; + color: inherit; + display: flex; + flex-direction: row; + text-decoration: none !important; - &:hover { - border-color: var(--grey); - } + &:hover { + border-color: var(--grey); + } - .attachments { - margin: 0; - border-radius: 0; - } + .attachments { + margin: 0; + border-radius: 0; + } } .card-content { - padding: 0.5em; + padding: 0.5em; } .card-title { - @include ellipsis; - white-space: unset; - font-weight: bold; - font-size: 1.1em; + @include ellipsis; + white-space: unset; + font-weight: bold; + font-size: 1.1em; } .card-description { - margin: 0.3em 0; - white-space: pre-wrap; + margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { - @include ellipsis; - color: var(--grey); - display: block; + @include ellipsis; + color: var(--grey); + display: block; } .card-content-container { - color: unset; - overflow: auto; - - &:hover { - text-decoration: none; - } + color: unset; + overflow: auto; + &:hover { + text-decoration: none; + } } .card-image-container { - width: 98px; - flex-shrink: 0; - position: relative; - overflow: hidden; - - &:before { - content: ""; - display: block; - padding-top: 100%; - } + width: 98px; + flex-shrink: 0; + position: relative; + overflow: hidden; + &:before { + content: ""; + display: block; + padding-top: 100%; + } } .card-image { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background-color: var(--bg_overlays); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg_overlays); - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + img { + width: 100%; + height: 100%; + max-height: 400px; + display: block; + object-fit: cover; + } } .card-overlay { - @include play-button; - opacity: 0.8; - display: flex; - justify-content: center; - align-items: center; + @include play-button; + opacity: 0.8; + display: flex; + justify-content: center; + align-items: center; } .large { - .card-container { - display: block; - } - - .card-image-container { - width: unset; - - &:before { - display: none; + .card-container { + display: block; } - } - .card-image { - position: unset; - border-style: solid; - border-color: var(--dark_grey); - border-width: 0; - border-bottom-width: 1px; - } + .card-image-container { + width: unset; + + &:before { + display: none; + } + } + + .card-image { + position: unset; + border-style: solid; + border-color: var(--dark_grey); + border-width: 0; + border-bottom-width: 1px; + } } diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss index 9ff8403..227fc5e 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -1,159 +1,17 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; -// Embed page: transparent background, no scrollbars -html:has(body > .embed-wrapper), -html:has(body > .embed-video) { - background: transparent; - overflow: hidden; - - body { - background: transparent; - overflow: hidden; - } -} - -// Tweet embed wrapper -.embed-wrapper { - box-sizing: border-box; - border: 1px solid var(--border_grey); - border-radius: 12px; - overflow: hidden; - - .embed-footer { - display: block; - padding: 12px 16px; - border-top: 1px solid var(--border_grey); - background: var(--bg_panel); - color: var(--accent); - font-size: 14px; - font-weight: 500; - text-align: center; - text-decoration: none; - transition: background-color 0.15s; - - &:hover { - background: var(--bg_hover); - } - } -} - -// Tweet embed content -.tweet-embed { - position: relative; - background-color: var(--bg_panel); - transition: background-color 0.15s; - - &:hover { - background-color: var(--bg_hover); - } - - .timeline-item { - pointer-events: none; - background-color: transparent; - } - - .tweet-link:hover { - background-color: transparent; - } - - .tweet-content { - font-size: 18px; - } - - .avatar:not(.mini) { - position: absolute; - } - - // Cap media height in embeds - .still-image img, - .quote-media-container img, - .quote-media-container video { - max-height: 600px; - } - - &.error-embed { - display: flex; - align-items: center; - justify-content: center; - min-height: 120px; - padding: 20px; - - .error-panel { - margin: 0; - } - } -} - -// Video-only embed .embed-video { - position: relative; - min-height: 300px; - background-color: black; - border: 1px solid var(--border_grey); - - .attachments { - margin: 0; - border-radius: 0; - max-height: 560px; - background-color: unset; - } - - .card { - margin: 0; - } - - .gallery-video { - width: 100%; - } - - .gallery-video>.attachment { - max-height: 560px; - width: 100%; - } - - video { - width: 100%; - height: auto; - max-height: 560px; - object-fit: contain; - } - - .video-download { - display: none; - } - - .video-overlay-link { - position: absolute; - top: 12px; - right: 12px; - padding: 6px 12px; - background: rgba(30, 30, 30, 0.75); - backdrop-filter: blur(4px); - color: #fff; - font-size: 13px; - font-weight: 700; - text-decoration: none; - border-radius: 9999px; - border: 1px solid transparent; - transition: - background 0.15s, - opacity 0.15s; - z-index: 10; - - &:hover { - background: rgba(60, 60, 60, 0.9); + .gallery-video { + width: 100%; + height: 100%; + position: absolute; + background-color: black; + top: 0%; + left: 0%; } - } - // Hide button while playing, show on hover or when paused - &.video-playing .video-overlay-link { - opacity: 0; - pointer-events: none; - } - - &.video-playing:hover .video-overlay-link { - opacity: 1; - pointer-events: auto; - } + .video-container { + max-height: unset; + } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 3001a86..91c9dab 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -1,168 +1,119 @@ -@import "_variables"; +@import '_variables'; .gallery-row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - overflow: hidden; - flex-grow: 1; - max-height: 379.5px; - max-width: 533px; - pointer-events: all; - - &.mixed-row { - .attachment { - min-width: 0; - min-height: 0; - flex: 1 1 0; - max-height: 379.5px; - display: flex; - align-items: center; - justify-content: center; - background-color: #101010; - } - - .still-image, - .still-image img, - .attachment > video, - .attachment > img { - width: 100%; - height: 100%; - max-width: none; - max-height: none; - } + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + overflow: hidden; + flex-grow: 1; + max-height: 379.5px; + max-width: 533px; + pointer-events: all; .still-image { - display: flex; - align-self: stretch; + width: 100%; + display: flex; } - - .still-image img { - flex-basis: auto; - flex-grow: 0; - object-fit: cover; - } - - .attachment > video, - .attachment > img { - object-fit: cover; - } - - .attachment > video { - object-fit: contain; - } - } } .attachments { - margin-top: 0.35em; - display: flex; - flex-direction: row; - width: 100%; - max-height: 600px; - border-radius: 7px; - overflow: hidden; - flex-flow: column; - background-color: var(--bg_color); - align-items: center; - pointer-events: all; + margin-top: .35em; + display: flex; + flex-direction: row; + width: 100%; + max-height: 600px; + border-radius: 7px; + overflow: hidden; + flex-flow: column; + background-color: var(--bg_color); + align-items: center; + pointer-events: all; + + .image-attachment { + width: 100%; + } } .attachment { - position: relative; - line-height: 0; - overflow: hidden; - margin: 0 0.25em 0 0; - flex-grow: 1; - box-sizing: border-box; - min-width: 2em; + position: relative; + line-height: 0; + overflow: hidden; + margin: 0 .25em 0 0; + flex-grow: 1; + box-sizing: border-box; + min-width: 2em; - &:last-child { - margin: 0; + &:last-child { + margin: 0; + max-height: 530px; + } +} + +.gallery-gif video { max-height: 530px; - } -} - -.media-gif { - display: table; - background-color: unset; - width: unset; - max-height: unset; -} - -.media-gif video, -.media-gif img { - width: 100%; - height: 100%; - max-height: 530px; - background-color: #101010; + background-color: #101010; } .still-image { - max-height: 379.5px; - max-width: 533px; - - img { - object-fit: cover; - max-width: 100%; max-height: 379.5px; - flex-basis: 300px; - flex-grow: 1; - } + max-width: 533px; + justify-content: center; + + img { + object-fit: cover; + max-width: 100%; + max-height: 379.5px; + flex-basis: 300px; + flex-grow: 1; + } } -.alt-text { - margin: 0px; - padding: 11px 7px; - box-sizing: border-box; - position: absolute; - bottom: 10px; - left: 10px; - width: 2.98em; - max-height: 25px; - white-space: pre; - overflow: hidden; - border-radius: 10px; - color: var(--fg_color); - font-size: 12px; - font-weight: bold; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(12px); +.image { + display: inline-block; } -.alt-text:hover { - padding: 7px; - width: Min(230px, calc(100% - 10px * 2)); - max-height: calc(100% - 10px); - line-height: 1.2em; - white-space: pre-wrap; - transition-duration: 0.4s; - transition-property: max-height; -} +// .single-image { +// display: inline-block; +// width: 100%; +// max-height: 600px; + +// .attachments { +// width: unset; +// max-height: unset; +// display: inherit; +// } +// } .overlay-circle { - border-radius: 50%; - background-color: var(--dark_grey); - width: 40px; - height: 40px; - align-items: center; - display: flex; - border-width: 5px; - border-color: var(--play_button); - border-style: solid; + border-radius: 50%; + background-color: var(--dark_grey); + width: 40px; + height: 40px; + align-items: center; + display: flex; + border-width: 5px; + border-color: var(--play_button); + border-style: solid; } .overlay-triangle { - width: 0; - height: 0; - border-style: solid; - border-width: 12px 0 12px 17px; - border-color: transparent transparent transparent var(--play_button); - margin-left: 14px; + width: 0; + height: 0; + border-style: solid; + border-width: 12px 0 12px 17px; + border-color: transparent transparent transparent var(--play_button); + margin-left: 14px; +} + +.media-gif { + display: table; + background-color: unset; + width: unset; } .media-body { - flex: 1; - padding: 0; - white-space: pre-wrap; + flex: 1; + padding: 0; + white-space: pre-wrap; } diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss index 6d54e00..57590c8 100644 --- a/src/sass/tweet/poll.scss +++ b/src/sass/tweet/poll.scss @@ -1,42 +1,42 @@ -@import "_variables"; +@import '_variables'; .poll-meter { - overflow: hidden; - position: relative; - margin: 6px 0; - height: 26px; - background: var(--bg_color); - border-radius: 5px; - display: flex; - align-items: center; + overflow: hidden; + position: relative; + margin: 6px 0; + height: 26px; + background: var(--bg_color); + border-radius: 5px; + display: flex; + align-items: center; } .poll-choice-bar { - height: 100%; - position: absolute; - background: var(--dark_grey); + height: 100%; + position: absolute; + background: var(--dark_grey); } .poll-choice-value { - position: relative; - font-weight: bold; - margin-left: 5px; - margin-right: 6px; - min-width: 30px; - text-align: right; - pointer-events: all; + position: relative; + font-weight: bold; + margin-left: 5px; + margin-right: 6px; + min-width: 30px; + text-align: right; + pointer-events: all; } .poll-choice-option { - position: relative; - pointer-events: all; + position: relative; + pointer-events: all; } .poll-info { - color: var(--grey); - pointer-events: all; + color: var(--grey); + pointer-events: all; } .leader .poll-choice-bar { - background: var(--accent_dark); + background: var(--accent_dark); } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index f722455..b4bc60e 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -1,121 +1,94 @@ -@import "_variables"; +@import '_variables'; .quote { - margin-top: 10px; - border: solid 1px var(--dark_grey); - border-radius: 10px; - background-color: var(--bg_elements); - overflow: hidden; - pointer-events: all; - position: relative; - width: 100%; - - &:hover { - border-color: var(--grey); - } - - &.unavailable:hover { - border-color: var(--dark_grey); - } - - .tweet-name-row { - padding: 8px 10px 6px 10px; - } - - .quote-text { + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); overflow: hidden; - white-space: pre-wrap; - word-wrap: break-word; - padding: 10px; - padding-top: 0; - } - - .show-thread { - padding: 0px 10px 6px 10px; - margin-top: -6px; - } - - .quote-latest { - padding: 0px 10px 6px 10px; - color: var(--grey); - } - - .replying-to { - padding: 0px 10px; - padding-bottom: 4px; - margin: unset; - } - - .community-note { - background-color: var(--bg_panel); - border: unset; - border-top: solid 1px var(--dark_grey); - border-radius: unset; - margin-top: 0; + pointer-events: all; + position: relative; + width: 100%; &:hover { - border-top-color: var(--grey); + border-color: var(--grey); } - .community-note-header { - background-color: var(--bg_panel); - padding-bottom: 0; + &.unavailable:hover { + border-color: var(--dark_grey); + } + + .tweet-name-row { + padding: 6px 8px; + margin-top: 1px; + } + + .quote-text { + overflow: hidden; + white-space: pre-wrap; + word-wrap: break-word; + padding: 0px 8px 8px 8px; + } + + .show-thread { + padding: 0px 8px 6px 8px; + margin-top: -6px; + } + + .replying-to { + padding: 0px 8px; + margin: unset; } - } } .unavailable-quote { - padding: 12px; - display: block; + padding: 12px; } .quote-link { - width: 100%; - height: 100%; - left: 0; - top: 0; - position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + position: absolute; } .quote-media-container { - max-height: 300px; - display: flex; - - .card { - margin: unset; - } - - .attachments { - border-radius: 0; - } - - .media-gif { - width: 100%; - display: flex; - justify-content: center; - } - - .media-gif > .attachment { - display: flex; - justify-content: center; - background-color: var(--bg_color); - - video, - img { - height: unset; - width: unset; - max-height: 100%; - max-width: 100%; - } - } - - .gallery-row .attachment, - .gallery-row .attachment > video, - .gallery-row .attachment > img { max-height: 300px; - } + display: flex; - .still-image img { - max-height: 250px; - } + .card { + margin: unset; + } + + .attachments { + border-radius: 0; + } + + .media-gif { + width: 100%; + display: flex; + justify-content: center; + } + + .gallery-gif .attachment { + display: flex; + justify-content: center; + background-color: var(--bg_color); + + video { + height: unset; + width: unset; + max-height: 100%; + max-width: 100%; + } + } + + .gallery-video, .gallery-gif { + max-height: 300px; + } + + .still-image img { + max-height: 250px + } } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 134e375..19fb3e0 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,196 +1,138 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; -.conversation, -.edit-history { - @include panel(100%, 600px); +.conversation { + @include panel(100%, 600px); - .show-more { - margin-bottom: 10px; - } + .show-more { + margin-bottom: 10px; + } } -.main-thread, -.latest-edit { - margin-bottom: 20px; +.main-thread { + margin-bottom: 20px; + background-color: var(--bg_panel); } -.reply { - margin-bottom: 10px; -} - -.reply-sort { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 2px 14px; - margin-bottom: 10px; - padding: 8px 12px; - background-color: var(--bg_panel); - font-size: 14px; -} - -.reply-sort-label { - color: var(--fg_faded); - margin-right: 2px; -} - -.reply-sort-option { - color: var(--tab); - font-weight: bold; - text-decoration: none; - border-bottom: 0.1rem solid transparent; - - &:hover { - color: var(--fg_color); - text-decoration: none; - } - - &.active { - color: var(--tab_selected); - border-bottom-color: var(--tab_selected); - } -} - -.main-tweet, -.replies, -.edit-history > div { - body.fixed-nav & { +.main-tweet, .replies { padding-top: 50px; margin-top: -50px; - } -} - -.edit-history-header { - padding: 10px; - margin-bottom: 5px; - font-size: 16px; - font-weight: bold; - background-color: var(--bg_panel); -} - -.tweet-edit { - margin-bottom: 5px; } .main-tweet .tweet-content { - font-size: 18px; + font-size: 18px; } -@media (max-width: 600px) { - .main-tweet .tweet-content { - font-size: 16px; - } +@media(max-width: 600px) { + .main-tweet .tweet-content { + font-size: 16px; + } +} + +.reply { + background-color: var(--bg_panel); + margin-bottom: 10px; } .thread-line { - .timeline-item::before, - &.timeline-item::before { - background: var(--accent_dark); - content: ""; - position: relative; - min-width: 3px; - width: 3px; - left: 26px; - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - top: 56px; - z-index: 1; - pointer-events: none; - } + .timeline-item::before, + &.timeline-item::before { + background: var(--accent_dark); + content: ''; + position: relative; + min-width: 3px; + width: 3px; + left: 26px; + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + top: 56px; + z-index: 1; + pointer-events: none; + } - .with-header:not(:first-child)::after { - background: var(--accent_dark); - content: ""; - position: relative; - float: left; - min-width: 3px; - width: 3px; - right: calc(100% - 26px); - border-radius: 2px; - margin-left: -3px; - margin-bottom: 37px; - bottom: 10px; - height: 30px; - z-index: 1; - pointer-events: none; - } + .with-header:not(:first-child)::after { + background: var(--accent_dark); + content: ''; + position: relative; + float: left; + min-width: 3px; + width: 3px; + right: calc(100% - 26px); + border-radius: 2px; + margin-left: -3px; + margin-bottom: 37px; + bottom: 10px; + height: 30px; + z-index: 1; + pointer-events: none; + } - .unavailable::before { - top: 48px; - margin-bottom: 28px; - } + .unavailable::before { + top: 48px; + margin-bottom: 28px; + } - .more-replies::before { - content: "..."; - background: unset; - color: var(--more_replies_dots); - font-weight: bold; - font-size: 20px; - line-height: 0.25em; - left: 1.2em; - width: 5px; - top: 2px; - margin-bottom: 0; - margin-left: -2.5px; - } + .more-replies::before { + content: '...'; + background: unset; + color: var(--more_replies_dots); + font-weight: bold; + font-size: 20px; + line-height: 0.25em; + left: 1.2em; + width: 5px; + top: 2px; + margin-bottom: 0; + margin-left: -2.5px; + } - .earlier-replies { - padding-bottom: 0; - margin-bottom: -5px; - } + .earlier-replies { + padding-bottom: 0; + margin-bottom: -5px; + } } .timeline-item.thread-last::before { - background: unset; - min-width: unset; - width: 0; - margin: 0; + background: unset; + min-width: unset; + width: 0; + margin: 0; } .more-replies { - padding-top: 0.3em !important; + padding-top: 0.3em !important; } .more-replies-text { - @include ellipsis; - display: block; - margin-left: 58px; - padding: 7px 0; + @include ellipsis; + display: block; + margin-left: 58px; + padding: 7px 0; } .timeline-item.thread.more-replies-thread { - padding: 0 0.75em; - - &::before { - top: 40px; - margin-bottom: 31px; - } - - .more-replies { - display: flex; - padding-top: unset !important; - margin-top: 8px; + padding: 0 0.75em; &::before { - display: inline-block; - position: relative; - top: -1px; - line-height: 0.4em; + top: 40px; + margin-bottom: 31px; } - .more-replies-text { - display: inline; - } - } -} + .more-replies { + display: flex; + padding-top: unset !important; + margin-top: 8px; -.related-header { - padding: 8px 12px; - margin-top: 10px; - background-color: var(--bg_panel); - color: var(--fg_faded); - font-size: 14px; - border-bottom: 1px solid var(--border_grey); + &::before { + display: inline-block; + position: relative; + top: -1px; + line-height: 0.4em; + } + + .more-replies-text { + display: inline; + } + } } diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index 28fc125..98a1c29 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -1,111 +1,68 @@ -@import "_variables"; -@import "_mixins"; +@import '_variables'; +@import '_mixins'; video { - height: 100%; - width: 100%; + max-height: 100%; + width: 100%; } .gallery-video { - display: flex; - overflow: hidden; - - &.card-container { - flex-direction: column; - width: 100%; - } + display: flex; + overflow: hidden; +} - > .attachment { +.gallery-video.card-container { + flex-direction: column; +} + +.video-container { min-height: 80px; min-width: 200px; max-height: 530px; margin: 0; + display: flex; + align-items: center; + justify-content: center; img { - max-height: 100%; - max-width: 100%; + max-height: 100%; + max-width: 100%; } - } -} - -.video-download { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - color: var(--accent); - text-decoration: none; - opacity: 0; - transition: opacity 0.2s; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7)); - - .icon-container { - margin: 0; - } - - .icon-download-alt { - font-size: 18px; - } -} - -.attachment:hover .video-download { - opacity: 1; - - &:hover { - color: var(--fg_color); - } -} - -@media (hover: none) { - .video-download { - opacity: 1; - } } .video-overlay { - @include play-button; - background-color: $shadow; + @include play-button; + background-color: $shadow; - p { - position: relative; - z-index: 0; - text-align: center; - top: calc(50% - 20px); - font-size: 20px; - line-height: 1.3; - margin: 0 20px; - } + p { + position: relative; + z-index: 0; + text-align: center; + top: calc(50% - 20px); + font-size: 20px; + line-height: 1.3; + margin: 0 20px; + } - .overlay-circle { - position: relative; - z-index: 0; - top: calc(50% - 20px); - margin: 0 auto; - width: 40px; - height: 40px; - } + div { + position: relative; + z-index: 0; + top: calc(50% - 20px); + margin: 0 auto; + width: 40px; + height: 40px; + } - .overlay-duration { - position: absolute; - bottom: 8px; - left: 8px; - background-color: #0000007a; - line-height: 1em; - padding: 4px 6px 4px 6px; - border-radius: 5px; - font-weight: bold; - } + form { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + display: flex; + } - form { - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - display: flex; - } - - button { - padding: 5px 8px; - font-size: 16px; - } + button { + padding: 5px 8px; + font-size: 16px; + } } diff --git a/src/tid.nim b/src/tid.nim deleted file mode 100644 index ba1f8ec..0000000 --- a/src/tid.nim +++ /dev/null @@ -1,64 +0,0 @@ -import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times] -import nimcrypto -import experimental/parser/tid - -randomize() - -const defaultKeyword = "obfiowerehiring"; -const pairsUrl = - "https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json"; - -var - cachedPairs: seq[TidPair] = @[] - lastCached = 0 - # refresh every hour - ttlSec = 60 * 60 - -proc getPair(): Future[TidPair] {.async.} = - if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec: - let client = newAsyncHttpClient() - defer: client.close() - - let resp = await client.get(pairsUrl) - if resp.status == $Http200: - cachedPairs = parseTidPairs(await resp.body) - lastCached = int(epochTime()) - - if cachedPairs.len == 0: - raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs") - - return sample(cachedPairs) - -proc encodeSha256(text: string): array[32, byte] = - let - data = cast[ptr byte](addr text[0]) - dataLen = uint(len(text)) - digest = sha256.digest(data, dataLen) - return digest.data - -proc encodeBase64[T](data: T): string = - return encode(data).replace("=", "") - -proc decodeBase64(data: string): seq[byte] = - return cast[seq[byte]](decode(data)) - -proc genTid*(path: string): Future[string] {.async.} = - let - pair = await getPair() - - timeNow = int(epochTime() - 1682924400) - timeNowBytes = @[ - byte(timeNow and 0xff), - byte((timeNow shr 8) and 0xff), - byte((timeNow shr 16) and 0xff), - byte((timeNow shr 24) and 0xff) - ] - - data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey - hashBytes = encodeSha256(data) - keyBytes = decodeBase64(pair.verification) - bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8] - randomNum = byte(rand(256)) - tid = @[randomNum] & bytesArr.mapIt(it xor randomNum) - - return encodeBase64(tid) diff --git a/src/types.nim b/src/types.nim index 0a748ba..f16fe6f 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import times, sequtils, options, tables +import times, sequtils, options, tables, uri import prefs_impl genPrefsType() @@ -11,16 +11,21 @@ type BadClientError* = object of CatchableError TimelineKind* {.pure.} = enum - tweets, replies, media, articles + tweets, replies, media - ApiUrl* = object - endpoint*: string - params*: seq[(string, string)] - skipTid*: bool - - ApiReq* = object - oauth*: ApiUrl - cookie*: ApiUrl + Api* {.pure.} = enum + tweetDetail + tweetResult + search + list + listBySlug + listMembers + listTweets + userRestId + userScreenName + userTweets + userTweetsAndReplies + userMedia RateLimit* = object limit*: int @@ -37,7 +42,7 @@ type pending*: int limited*: bool limitedAt*: int - apis*: Table[string, RateLimit] + apis*: Table[Api, RateLimit] case kind*: SessionKind of oauth: oauthToken*: string @@ -46,6 +51,10 @@ type authToken*: string ct0*: string + SessionAwareUrl* = object + oauthUrl*: Uri + cookieUrl*: Uri + Error* = enum null = 0 noUserMatches = 17 @@ -61,7 +70,6 @@ type rateLimited = 88 expiredToken = 89 listIdOrSlug = 112 - timelineUnavailable = 131 tweetNotFound = 144 tweetNotAuthorized = 179 forbidden = 200 @@ -98,59 +106,6 @@ type suspended*: bool joinDate*: DateTime - AccountInfo* = object - username*: string - fullname*: string - userPic*: string - joinDate*: DateTime - verifiedType*: VerifiedType - suspended*: bool - basedIn*: string - source*: string - usernameChanges*: int - lastUsernameChange*: DateTime - affiliateUsername*: string - affiliateLabel*: string - isIdentityVerified*: bool - verifiedSince*: DateTime - overrideVerifiedYear*: int - - Broadcast* = object - id*: string - title*: string - state*: string - thumb*: string - mediaKey*: string - m3u8Url*: string - totalWatched*: int - startTime*: DateTime - endTime*: DateTime - replayStart*: int - availableForReplay*: bool - user*: User - - SpaceParticipant* = object - userId*: string - username*: string - displayName*: string - avatarUrl*: string - isVerified*: bool - - AudioSpace* = object - id*: string - title*: string - state*: string - mediaKey*: string - m3u8Url*: string - totalLiveListeners*: int - totalReplayWatched*: int - startTime*: DateTime - endTime*: DateTime - availableForReplay*: bool - creator*: User - admins*: seq[SpaceParticipant] - speakers*: seq[SpaceParticipant] - VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" @@ -174,15 +129,10 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList, followers, following, lists, top, - articles - - RankingMode* = enum - Relevance, Recency, Likes + posts, replies, media, users, tweets, userList Query* = object kind*: QueryKind - view*: string text*: string filters*: seq[string] includes*: seq[string] @@ -190,33 +140,12 @@ type fromUser*: seq[string] since*: string until*: string - minLikes*: string + near*: string sep*: string Gif* = object url*: string thumb*: string - altText*: string - - Photo* = object - url*: string - altText*: string - - MediaKind* = enum - photoMedia - videoMedia - gifMedia - - Media* = object - case kind*: MediaKind - of photoMedia: - photo*: Photo - of videoMedia: - video*: Video - of gifMedia: - gif*: Gif - - MediaEntities* = seq[Media] GalleryPhoto* = object url*: string @@ -225,44 +154,6 @@ type PhotoRail* = seq[GalleryPhoto] - Article* = ref object - title*: string - coverImage*: string - user*: User - time*: DateTime - stats*: TweetStats - paragraphs*: seq[ArticleParagraph] - entities*: Table[int, ArticleEntity] - media*: Table[string, ArticleMedia] - - ArticleParagraph* = object - text*: string - kind*: string - inlineStyles*: seq[ArticleStyle] - entityRanges*: seq[ArticleEntityRange] - - ArticleStyle* = object - offset*: int - length*: int - style*: string - - ArticleEntityRange* = object - offset*: int - length*: int - key*: int - - ArticleEntity* = object - kind*: string - url*: string - mediaIds*: seq[string] - tweetId*: string - markdown*: string - caption*: string - - ArticleMedia* = object - kind*: string - url*: string - Poll* = object options*: seq[string] values*: seq[int] @@ -312,12 +203,6 @@ type likes*: int views*: int - ArticlePreview* = object - title*: string - previewText*: string - coverImage*: string - tweetId*: int64 - Tweet* = ref object id*: int64 threadId*: int64 @@ -336,17 +221,13 @@ type stats*: TweetStats retweet*: Option[Tweet] attribution*: Option[User] - attributionLink*: string mediaTags*: seq[User] quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] - media*: MediaEntities - history*: seq[int64] - note*: string - isAd*: bool - isAI*: bool - articlePreview*: Option[ArticlePreview] + gif*: Option[Gif] + video*: Option[Video] + photos*: seq[string] Tweets* = seq[Tweet] @@ -360,7 +241,6 @@ type content*: Tweets hasMore*: bool cursor*: string - related*: bool Conversation* = ref object tweet*: Tweet @@ -368,10 +248,6 @@ type after*: Chain replies*: Result[Chain] - EditHistory* = object - latest*: Tweet - history*: Tweets - Timeline* = Result[Tweets] Profile* = object @@ -379,7 +255,6 @@ type photoRail*: PhotoRail pinned*: Option[Tweet] tweets*: Timeline - accountInfo*: AccountInfo List* = object id*: string @@ -390,29 +265,6 @@ type members*: int banner*: string - ListSearchResult* = object - list*: List - owner*: User - followersContext*: string - facepiles*: seq[string] - - CommunityRule* = object - name*: string - description*: string - - Community* = object - id*: string - name*: string - description*: string - memberCount*: int - banner*: string - creator*: User - category*: string - joinPolicy*: string - createdAt*: DateTime - rules*: seq[CommunityRule] - hashtags*: seq[string] - GlobalObjects* = ref object tweets*: Table[string, Tweet] users*: Table[string, User] @@ -429,20 +281,10 @@ type hmacKey*: string base64Media*: bool minTokens*: int - enableRSSUserTweets*: bool - enableRSSUserReplies*: bool - enableRSSUserMedia*: bool - enableRSSUserArticles*: bool - enableRSSSearch*: bool - enableRSSList*: bool + enableRss*: bool enableDebug*: bool proxy*: string proxyAuth*: string - apiProxy*: string - disableTid*: bool - maxConcurrentReqs*: int - maxRetries*: int - retryDelayMs*: int rssCacheTime*: int listCacheTime*: int @@ -461,24 +303,3 @@ proc contains*(thread: Chain; tweet: Tweet): bool = proc add*(timeline: var seq[Tweets]; tweet: Tweet) = timeline.add @[tweet] - -proc getPhotos*(tweet: Tweet): seq[Photo] = - tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo) - -proc getVideos*(tweet: Tweet): seq[Video] = - tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video) - -proc hasPhotos*(tweet: Tweet): bool = - tweet.media.anyIt(it.kind == photoMedia) - -proc hasVideos*(tweet: Tweet): bool = - tweet.media.anyIt(it.kind == videoMedia) - -proc hasGifs*(tweet: Tweet): bool = - tweet.media.anyIt(it.kind == gifMedia) - -proc getThumb*(media: Media): string = - case media.kind - of photoMedia: media.photo.url - of videoMedia: media.video.thumb - of gifMedia: media.gif.thumb diff --git a/src/utils.nim b/src/utils.nim index 95b46de..c96a6dd 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import sequtils, strutils, strformat, uri, tables, base64 +import strutils, strformat, uri, tables, base64 import nimcrypto var @@ -9,7 +9,7 @@ var const https* = "https://" twimg* = "pbs.twimg.com/" - nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"] + nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] twitterDomains = @[ "twitter.com", "pic.twitter.com", @@ -17,9 +17,7 @@ const "abs.twimg.com", "pbs.twimg.com", "video.twimg.com", - "x.com", - "pscp.tv", - "video.pscp.tv" + "x.com" ] proc setHmacKey*(key: string) = @@ -40,14 +38,12 @@ proc getVidUrl*(link: string): string = &"/video/{sig}/{encodeUrl(link)}" proc getPicUrl*(link: string): string = - if link.len == 0: return if base64Media: &"/pic/enc/{encode(link, safe=true)}" else: &"/pic/{encodeUrl(link)}" proc getOrigPicUrl*(link: string): string = - if link.len == 0: return if base64Media: &"/pic/orig/enc/{encode(link, safe=true)}" else: @@ -59,13 +55,7 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p proc isTwitterUrl*(uri: Uri): bool = - uri.scheme in ["http", "https"] and - (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv")) + uri.hostname in twitterDomains proc isTwitterUrl*(url: string): bool = isTwitterUrl(parseUri(url)) - -proc validateNumber*(value: string): string = - if value.anyIt(not it.isDigit): - return "" - return value diff --git a/src/views/about_account.nim b/src/views/about_account.nim deleted file mode 100644 index aedd444..0000000 --- a/src/views/about_account.nim +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times -import karax/[karaxdsl, vdom] - -import renderutils -import ".."/[types, formatters] - -proc renderAboutAccount*(info: AccountInfo): VNode = - let user = User( - username: info.username, - fullname: info.fullname, - userPic: info.userPic, - verifiedType: info.verifiedType - ) - - buildHtml(tdiv(class="about-account")): - tdiv(class="about-account-header"): - a(class="about-account-avatar", href=(&"/{info.username}")): - genImg(getUserPic(info.userPic, "_200x200")) - tdiv(class="about-account-name"): - linkUser(user, class="profile-card-fullname") - verifiedIcon(user) - linkUser(user, class="profile-card-username") - - tdiv(class="about-account-body"): - tdiv(class="about-account-row"): - span: icon "calendar" - tdiv: - span(class="about-account-label"): text "Date joined" - span(class="about-account-value"): - text info.joinDate.format("MMMM YYYY") - - if info.basedIn.len > 0: - tdiv(class="about-account-row"): - span: icon "location" - tdiv: - span(class="about-account-label"): text "Account based in" - span(class="about-account-value"): text info.basedIn - - if info.verifiedType != VerifiedType.none: - if info.overrideVerifiedYear != 0: - tdiv(class="about-account-row"): - span: icon "ok" - tdiv: - span(class="about-account-label"): text "Verified" - span(class="about-account-value"): - let year = abs(info.overrideVerifiedYear) - let era = if info.overrideVerifiedYear < 0: " BCE" else: "" - text "Since " & $year & era - elif info.verifiedSince.year > 0: - tdiv(class="about-account-row"): - span: icon "ok" - tdiv: - span(class="about-account-label"): text "Verified" - span(class="about-account-value"): - text "Since " & info.verifiedSince.format("MMMM YYYY") - - if info.isIdentityVerified: - tdiv(class="about-account-row"): - span: icon "ok" - tdiv: - span(class="about-account-label"): text "ID Verified" - span(class="about-account-value"): text "Yes" - - if info.affiliateUsername.len > 0: - tdiv(class="about-account-row"): - span: icon "group" - tdiv: - span(class="about-account-label"): text "An affiliate of" - span(class="about-account-value"): - a(href=(&"/{info.affiliateUsername}")): - if info.affiliateLabel.len > 0: - text info.affiliateLabel & " (@" & info.affiliateUsername & ")" - else: - text "@" & info.affiliateUsername - - if info.usernameChanges > 0: - tdiv(class="about-account-row"): - span(class="about-account-at"): text "@" - tdiv: - span(class="about-account-label"): - text $info.usernameChanges & " username change" - if info.usernameChanges > 1: text "s" - if info.lastUsernameChange.year > 0: - span(class="about-account-value"): - text "Last on " & info.lastUsernameChange.format("MMMM YYYY") - - if info.source.len > 0: - tdiv(class="about-account-row"): - span: icon "link" - tdiv: - span(class="about-account-label"): text "Connected via" - span(class="about-account-value"): text info.source diff --git a/src/views/article.nim b/src/views/article.nim deleted file mode 100644 index eedd4cf..0000000 --- a/src/views/article.nim +++ /dev/null @@ -1,248 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, tables, unicode, bitops, uri -import karax/[karaxdsl, vdom] - -import renderutils, tweet, timeline -import ".."/[types, utils, formatters] - -proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article; - tweets: Table[int64, Tweet]; path: string; - prefs: Prefs): VNode = - if paragraph.entityRanges.len == 0: - return text "" - - let er = paragraph.entityRanges[0] - if er.key notin article.entities: - return text "" - - let entity = article.entities[er.key] - - case entity.kind - of "MEDIA": - buildHtml(tdiv(class="article-media")): - for id in entity.mediaIds: - let media = article.media.getOrDefault(id) - if media.url.len == 0: - continue - case media.kind - of "ApiGif": - video(src=getVidUrl(media.url), controls="", autoplay="", loop="", - muted="") - of "ApiVideo": - video(src=getVidUrl(media.url), controls="") - else: - a(href=getOrigPicUrl(media.url), target="_blank"): - img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy") - if entity.caption.len > 0: - p(class="article-media-caption"): text entity.caption - of "TWEET": - let tweet = tweets.getOrDefault( - try: parseBiggestInt(entity.tweetId) - except ValueError: 0, nil) - if tweet != nil: - renderTweet(tweet, prefs, path) - else: - text "" - of "MARKDOWN": - var content = entity.markdown - if content.startsWith("```"): - let firstNl = content.find('\n') - if firstNl >= 0: content = content[firstNl + 1 .. ^1] - if content.endsWith("```"): content = content[0 .. ^4] - content = content.strip - buildHtml(pre()): - code(): text content - of "DIVIDER": - buildHtml(hr(class="article-divider")) - else: - text "" - -proc wrapStyle(node: VNode; style: int): VNode = - result = node - if style.testBit(4): result = buildHtml(code()): result - if style.testBit(0): result = buildHtml(strong()): result - if style.testBit(1): result = buildHtml(em()): result - if style.testBit(2): result = buildHtml(del()): result - if style.testBit(3): result = buildHtml(underlined()): result - -proc addContent(target: VNode; content: string; style = 0) = - var first = true - for line in content.split('\n'): - if not first: - target.add VNode(kind: VNodeKind.br) - first = false - var pos = 0 - while pos < line.len: - let atPos = line.find('@', pos) - if atPos == -1: - target.add wrapStyle(text line[pos .. ^1], style) - break - if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}: - target.add wrapStyle(text line[pos .. atPos], style) - pos = atPos + 1 - continue - var j = atPos + 1 - while j < line.len and j - atPos - 1 < 15 and - line[j] in Letters + Digits + {'_'}: - inc j - if j == atPos + 1: - target.add wrapStyle(text line[pos .. atPos], style) - pos = atPos + 1 - continue - if atPos > pos: - target.add wrapStyle(text line[pos ..< atPos], style) - let username = line[atPos + 1 ..< j] - let link = a.newVNode() - link.setAttr("href", "/" & username) - link.add wrapStyle(text ("@" & username), style) - target.add link - pos = j - -proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int; - styles: seq[ArticleStyle]) = - if styles.len == 0: - target.addContent($runes[start ..< start + length]) - return - - var - lastStyle = 0 - lastStart = start - let endPos = start + length - - for i in start ..< endPos: - var style = 0 - for sr in styles: - let - sStart = sr.offset - sEnd = sStart + sr.length - if sStart <= i and sEnd > i: - case sr.style - of "Bold": style.setBit(0) - of "Italic": style.setBit(1) - of "Strikethrough": style.setBit(2) - of "Underline": style.setBit(3) - of "Code": style.setBit(4) - else: discard - - if style != lastStyle: - if i > lastStart: - addContent(target, $runes[lastStart ..< i], lastStyle) - lastStyle = style - lastStart = i - - if lastStart < endPos: - addContent(target, $runes[lastStart ..< endPos], lastStyle) - -proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode = - let text = paragraph.text - - result = case paragraph.kind - of "header-one": h1.newVNode() - of "header-two": h2.newVNode() - of "header-three": h3.newVNode() - of "ordered-list-item", "unordered-list-item": li.newVNode() - of "blockquote": VNode(kind: VNodeKind.blockquote) - of "code-block": - let pre = pre.newVNode() - let code = code.newVNode() - code.add text text - pre.add code - return pre - else: p.newVNode() - - let - runes = text.toRunes - textLen = runes.len - var last = 0 - for er in paragraph.entityRanges: - if er.offset > last: - applyInlineStyles(result, runes, last, er.offset - last, - paragraph.inlineStyles) - - last = er.offset + er.length - - var target = result - if er.key in article.entities: - let entity = article.entities[er.key] - if entity.kind == "LINK": - let parsed = parseUri(entity.url) - if parsed.scheme in ["http", "https"]: - target = a.newVNode() - if parsed.isTwitterUrl: - target.setAttr("href", parsed.path) - else: - target.setAttr("href", entity.url) - - applyInlineStyles(target, runes, er.offset, er.length, - paragraph.inlineStyles) - if target != result: - result.add target - - if last < textLen: - applyInlineStyles(result, runes, last, textLen - last, - paragraph.inlineStyles) - - if paragraph.kind == "blockquote" and result.len > 0: - let lastChild = result[result.len - 1] - if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and - lastChild[0].kind == VNodeKind.text: - lastChild.setAttr("class", "blockquote-attribution") - -proc renderArticle*(article: Article; tweets: Table[int64, Tweet]; - path: string; prefs: Prefs; tweetId=""): VNode = - let author = article.user - - let main = buildHtml(article(class="article-body")): - h1(class="article-title"): text article.title - - tdiv(class="article-author"): - tdiv(class="article-author-row"): - a(class="article-avatar", href=("/" & author.username)): - genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass) - tdiv(class="article-author-info"): - tdiv(class="article-author-name"): - linkUser(author, class="fullname") - verifiedIcon(author) - tdiv(class="article-author-meta"): - linkUser(author, class="username") - span(class="article-date-sep"): text " · " - a(class="article-date", - href=("/" & author.username & "/status/" & tweetId)): - text article.time.getShortTime - if not prefs.hideTweetStats: - renderStats(article.stats) - - var listKind = "" - var list: VNode = nil - - for paragraph in article.paragraphs: - let isListItem = paragraph.kind in [ - "ordered-list-item", "unordered-list-item"] - - if not isListItem and list != nil: - main.add list - list = nil - listKind = "" - - if paragraph.kind == "atomic": - main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs) - elif isListItem: - if paragraph.kind != listKind: - if list != nil: - main.add list - list = if paragraph.kind == "ordered-list-item": ol.newVNode() - else: ul.newVNode() - listKind = paragraph.kind - list.add renderTextParagraph(paragraph, article) - else: - main.add renderTextParagraph(paragraph, article) - - if list != nil: - main.add list - - buildHtml(tdiv(class="article-page")): - if article.coverImage.len > 0: - a(href=getOrigPicUrl(article.coverImage), target="_blank"): - img(class="article-cover", src=getSmallPic(article.coverImage), alt="") - main - renderToTop() diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim deleted file mode 100644 index bfcb9ba..0000000 --- a/src/views/broadcast.nim +++ /dev/null @@ -1,75 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times -import karax/[karaxdsl, vdom] - -import renderutils -import ".."/[types, utils, formatters] - -proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode = - let - isLive = bc.state == "RUNNING" - thumb = getPicUrl(bc.thumb) - source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"): - getVidUrl(bc.m3u8Url) else: bc.m3u8Url - stateText = - if isLive: "LIVE" - elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY") - elif bc.state.len > 0: bc.state - else: "Ended" - durationMs = - if bc.startTime.year > 1 and bc.endTime.year > 1: - int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000 - else: 0 - duration = if durationMs > 0: getDuration(durationMs) else: "" - - buildHtml(tdiv(class="broadcast-page")): - tdiv(class="broadcast-panel"): - tdiv(class="broadcast-player"): - if bc.m3u8Url.len > 0 and prefs.hlsPlayback: - video(poster=thumb, data-url=source, data-autoload="false", - data-start=($bc.replayStart), muted=prefs.muteVideos) - verbatim "
" - tdiv(class="overlay-circle"): span(class="overlay-triangle") - if isLive: - tdiv(class="broadcast-live"): text "LIVE" - elif duration.len > 0: - tdiv(class="overlay-duration"): text duration - verbatim "
" - elif bc.m3u8Url.len > 0: - img(src=thumb, alt=bc.title) - tdiv(class="video-overlay"): - buttonReferer "/enablehls", "Enable hls playback", path - if isLive: - tdiv(class="broadcast-live"): text "LIVE" - elif duration.len > 0: - tdiv(class="overlay-duration"): text duration - elif bc.thumb.len > 0: - img(src=thumb, alt=bc.title) - tdiv(class="video-overlay"): - if bc.availableForReplay: - p: text "Stream unavailable" - else: - p: text "Replay is not available" - else: - tdiv(class="video-overlay"): - p: text "Broadcast not found" - - tdiv(class="broadcast-info"): - h2(class="broadcast-title"): text bc.title - - tdiv(class="broadcast-user-row"): - a(class="broadcast-user", href=("/" & bc.user.username)): - genImg(getUserPic(bc.user.userPic, "_bigger")) - tdiv: - tdiv: - strong: text bc.user.fullname - verifiedIcon(bc.user) - span(class="broadcast-username"): text "@" & bc.user.username - - tdiv(class="broadcast-meta"): - if bc.totalWatched > 0: - span: text insertSep($bc.totalWatched, ',') & " views" - if isLive: - span(class="broadcast-live"): text stateText - else: - span: text stateText diff --git a/src/views/community.nim b/src/views/community.nim deleted file mode 100644 index 52f9041..0000000 --- a/src/views/community.nim +++ /dev/null @@ -1,128 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times -import karax/[karaxdsl, vdom] - -import renderutils -import ".."/[types, utils, formatters] - -proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode = - let - path = &"/i/communities/{community.id}" - q = Query(kind: kind) - buildHtml(tdiv): - ul(class="tab"): - li(class=q.getTabClass(posts)): - a(href=path): text "Top" - li(class=q.getTabClass(replies)): - a(href=(path & "/latest")): text "Latest" - li(class=q.getTabClass(media)): - a(href=(path & "/media")): text "Media" - li(class=q.getTabClass(userList)): - a(href=(path & "/about")): text "About" - if community.hashtags.len > 0: - tdiv(class="community-tags"): - for tag in community.hashtags: - let bare = tag.strip(chars={'#'}) - a(class="community-tag", - href=(&"/i/communities/{community.id}/hashtag/{bare}")): - text tag - -proc renderMemberTabs*(community: Community; isModerators: bool): VNode = - let path = &"/i/communities/{community.id}" - buildHtml(ul(class="tab")): - li(class=(if not isModerators: "tab-item active" else: "tab-item")): - a(href=(path & "/members")): text "All" - li(class=(if isModerators: "tab-item active" else: "tab-item")): - a(href=(path & "/moderators")): text "Moderators" - -proc renderHashtagHeader*(community: Community; tag: string): VNode = - buildHtml(tdiv(class="community-hashtag-header")): - h2(class="community-hashtag-title"): text "#" & tag - -proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode = - buildHtml(tdiv(class="community-about")): - tdiv(class="community-info"): - h2: text "Community Info" - tdiv(class="community-info-item"): - icon "group" - if community.joinPolicy == "Open": - text "Anyone can join this Community." - else: - text "Membership is by approval only." - - tdiv(class="community-info-item"): - icon "info" - text "All Communities are publicly visible." - - tdiv(class="community-info-item"): - icon "calendar" - let - date = community.createdAt.format("MMMM d, yyyy") - creator = community.creator.username - span: - text &"Created {date} by " - a(href=(&"/{creator}")): text &"@{creator}" - if community.creator.verifiedType != none: - verifiedIcon(community.creator) - - if community.rules.len > 0: - tdiv(class="community-rules"): - h2: text "Rules" - p(class="community-rules-intro"): - text "These are set and enforced by Community admins and are in addition to " - a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules" - text "." - - for i, rule in community.rules: - tdiv(class="community-rule"): - span(class="community-rule-number"): text $(i + 1) - tdiv(class="community-rule-content"): - strong: text rule.name - if rule.description.len > 0: - p: text rule.description - - if moderators.len > 0: - tdiv(class="community-moderators"): - h2: - text "Moderators" - a(class="community-mods-link", - href=(&"/i/communities/{community.id}/moderators")): - text "See all" - for user in moderators: - tdiv(class="community-moderator"): - a(href=(&"/{user.username}")): - genImg(user.getUserPic("_bigger"), class="community-mod-avatar") - tdiv(class="community-mod-info"): - a(href=(&"/{user.username}"), class="community-mod-name"): - text user.fullname - if user.verifiedType != none: - verifiedIcon(user) - a(href=(&"/{user.username}"), class="community-mod-username"): - text &"@{user.username}" - -proc renderCommunity*(body, nav: VNode; community: Community): VNode = - buildHtml(tdiv(class="timeline-container")): - if community.banner.len > 0: - tdiv(class="timeline-banner"): - a(href=getPicUrl(community.banner), target="_blank"): - genImg(community.banner) - - tdiv(class="community-header"): - h1(class="community-name"): - a(href=(&"/i/communities/{community.id}")): text community.name - - if community.category.len > 0: - span(class="community-category"): text community.category - - if community.description.len > 0: - tdiv(class="community-description"): - text community.description - - tdiv(class="community-stats"): - a(class="community-member-count", - href=(&"/i/communities/{community.id}/members")): - text insertSep($community.memberCount, ',') - text " Members" - - nav - body diff --git a/src/views/embed.nim b/src/views/embed.nim index 5136a35..ba49f45 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -1,76 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only +import options import karax/[karaxdsl, vdom] from jester import Request -import ".."/[types, formatters, prefs] +import ".."/[types, formatters] import general, tweet -const - doctype = "\n" - embedResizeJs = staticRead("../../public/js/embedResize.js") +const doctype = "\n" proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = - let - video = tweet.getVideos()[0] - thumb = video.thumb - vidUrl = getVideoEmbed(cfg, tweet.id) - prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos) - tweetUrl = getLink(tweet) - + let thumb = get(tweet.video).thumb + let vidUrl = getVideoEmbed(cfg, tweet.id) + let prefs = Prefs(hlsPlayback: true, mp4Playback: true) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) - base(target="_blank") body: tdiv(class="embed-video"): - renderVideo(video, prefs, "") - a(class="video-overlay-link", href=tweetUrl): - text "Watch on " & cfg.hostname - - script: - verbatim embedResizeJs - - result = doctype & $node - -proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = - let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, req) - base(target="_blank") - - body: - tdiv(class="embed-wrapper"): - tdiv(class="tweet-embed"): - a(class="tweet-link", href=getLink(tweet)) - renderTweet(tweet, prefs, path, mainTweet=true) - a(class="embed-footer", href=getLink(tweet)): - text "Read more on " & cfg.hostname - - script: - verbatim embedResizeJs - - result = doctype & $node - -proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request; - tweetId = ""; username = ""): string = - let link = if tweetId.len > 0: - if username.len > 0: "/" & username & "/status/" & tweetId - else: "/i/status/" & tweetId - else: "/" - - let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, req) - base(target="_blank") - - body: - tdiv(class="embed-wrapper"): - tdiv(class="tweet-embed error-embed"): - a(class="tweet-link", href=link) - tdiv(class="error-panel"): - span: text error - a(class="embed-footer", href=link): - text "Read more on " & cfg.hostname - - script: - verbatim embedResizeJs + renderVideo(get(tweet.video), prefs, "") result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index d979898..0091c74 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -29,17 +29,19 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" - if rss.len > 0: + if cfg.enableRss and rss.len > 0: icon "rss", title="RSS Feed", href=rss - icon "bird", title="Open in X", href=canonical + icon "bird", title="Open in Twitter", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; alternate=""; oembed=""): VNode = - let theme = prefs.theme.toTheme + rss=""; canonical=""): VNode = + var theme = prefs.theme.toTheme + if "theme" in req.params: + theme = req.params["theme"].toTheme let ogType = if video.len > 0: "video" @@ -50,8 +52,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=106") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) @@ -64,19 +66,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, href=opensearchUrl) - if alternate.len > 0: - link(rel="alternate", href=alternate, title="View on X") + if canonical.len > 0: + link(rel="canonical", href=canonical) - if rss.len > 0: + if cfg.enableRss and rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") - if oembed.len > 0: - let oembedTitle = if titleText.len > 0: titleText else: "oEmbed" - link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle) - if prefs.hlsPlayback: script(src="/js/hls.min.js", `defer`="") - script(src="/js/hlsPlayback.js?v=1", `defer`="") + script(src="/js/hlsPlayback.js", `defer`="") if prefs.infiniteScroll: script(src="/js/infiniteScroll.js", `defer`="") @@ -88,7 +86,6 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; text cfg.title meta(name="viewport", content="width=device-width, initial-scale=1.0") - meta(name="referrer", content="same-origin") meta(name="theme-color", content="#1F1F1F") meta(property="og:type", content=ogType) meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText)) @@ -101,7 +98,6 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="preload", type="image/png", href=bannerUrl, `as`="image") for url in images: - if url.len == 0: continue let preloadUrl = if "400x400" in url: getPicUrl(url) else: getSmallPic(url) link(rel="preload", type="image/png", href=preloadUrl, `as`="image") @@ -123,24 +119,20 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason link(rel="preload", type="font/woff2", `as`="font", - href="/fonts/fontello.woff2?59696369", crossorigin="anonymous") + href="/fonts/fontello.woff2?61663884", crossorigin="anonymous") proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; - images: seq[string] = @[]; banner=""; - twitterLink=""; oembed=""): string = + images: seq[string] = @[]; banner=""): string = - let twitterLink = - if twitterLink.len > 0: twitterLink - else: getTwitterLink(req.path, req.params) + let canonical = getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, twitterLink, oembed) + rss, canonical) - let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" - body(class=bodyClass): - renderNavbar(cfg, req, rss, twitterLink) + body: + renderNavbar(cfg, req, rss, canonical) tdiv(class="container"): body diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf deleted file mode 100644 index 4f7f947..0000000 --- a/src/views/oembed.nimf +++ /dev/null @@ -1,7 +0,0 @@ -#? stdtmpl(subsChar = '$', metaChar = '#') -## SPDX-License-Identifier: AGPL-3.0-only -#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = -# result = "" - -# result = result.strip() -#end proc diff --git a/src/views/preferences.nim b/src/views/preferences.nim index b051a01..1787704 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -32,8 +32,7 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; - prefsUrl: string): VNode = +proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = buildHtml(tdiv(class="overlay-panel")): fieldset(class="preferences"): form(`method`="post", action="/saveprefs", autocomplete="off"): @@ -41,14 +40,6 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; renderPrefs() - legend: text "Bookmark" - p(class="bookmark-note"): - text "Save this URL to restore your preferences (?prefs works on all pages)" - pre(class="prefs-code"): - text prefsUrl - p(class="bookmark-note"): - verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name." - h4(class="note"): text "Preferences are stored client-side using cookies without any personal information." diff --git a/src/views/profile.nim b/src/views/profile.nim index c9012ed..2b2e410 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -2,7 +2,7 @@ import strutils, strformat import karax/[karaxdsl, vdom, vstyles] -import renderutils, search, timeline +import renderutils, search import ".."/[types, utils, formatters] proc renderStat(num: int; class: string; text=""): VNode = @@ -12,14 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderStatLink(num: int; class, href: string): VNode = - buildHtml(li(class=class)): - a(href=href): - span(class="profile-stat-header"): text capitalizeAscii(class) - span(class="profile-stat-num"): - text insertSep($num, ',') - -proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = +proc renderUserCard*(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let @@ -33,7 +26,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = tdiv(class="profile-card-tabs-name"): linkUser(user, class="profile-card-fullname") - verifiedIcon(user) linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): @@ -53,11 +45,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = else: span: text place - if info.basedIn.len > 0: - tdiv(class="profile-location"): - span: icon "location" - span: text "Based in " & info.basedIn - if user.website.len > 0: tdiv(class="profile-website"): span: @@ -66,14 +53,14 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = a(href=url): text url.shortLink tdiv(class="profile-joindate"): - a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)): + span(title=getJoinDateFull(user)): icon "calendar", getJoinDate(user) tdiv(class="profile-card-extra-links"): ul(class="profile-statlist"): renderStat(user.tweets, "posts", text="Tweets") - renderStatLink(user.following, "following", &"/{user.username}/following") - renderStatLink(user.followers, "followers", &"/{user.username}/followers") + renderStat(user.following, "following") + renderStat(user.followers, "followers") renderStat(user.likes, "likes") proc renderPhotoRail(profile: Profile): VNode = @@ -106,7 +93,7 @@ proc renderBanner(banner: string): VNode = else: a(href=getPicUrl(banner), target="_blank"): genImg(banner) -proc renderProtected*(username: string): VNode = +proc renderProtected(username: string): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header timeline-protected"): h2: text "This account's tweets are protected." @@ -114,52 +101,19 @@ proc renderProtected*(username: string): VNode = proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] - let - isGalleryView = profile.tweets.query.kind == QueryKind.media and - profile.tweets.query.view == "gallery" - viewClass = if isGalleryView: " media-only" else: "" - buildHtml(tdiv(class=("profile-tabs" & viewClass))): - if not isGalleryView and not prefs.hideBanner: + buildHtml(tdiv(class="profile-tabs")): + if not prefs.hideBanner: tdiv(class="profile-banner"): renderBanner(profile.user.banner) - if not isGalleryView: - let sticky = if prefs.stickyProfile: " sticky" else: "" - tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs, profile.accountInfo) - if profile.photoRail.len > 0: - renderPhotoRail(profile) + let sticky = if prefs.stickyProfile: " sticky" else: "" + tdiv(class=("profile-tab" & sticky)): + renderUserCard(profile.user, prefs) + if profile.photoRail.len > 0: + renderPhotoRail(profile) if profile.user.protected: renderProtected(profile.user.username) else: renderTweetSearch(profile.tweets, prefs, path, profile.pinned) - -proc renderFollowTabs(user: User; activeTab: string): VNode = - buildHtml(ul(class="tab")): - for tab in ["Following", "Followers"]: - li(class=(if activeTab == tab: "tab-item active" else: "tab-item")): - a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab - -proc renderUserList*(user: User; results: Result[User]; prefs: Prefs; - path, activeTab: string): VNode = - # Check if we've loaded all results (for page 1) - var displayResults = results - if results.beginning: - let expectedCount = if activeTab == "Followers": user.followers else: user.following - if results.content.len >= expectedCount: - displayResults.bottom = "" - - buildHtml(tdiv(class="profile-tabs")): - if not prefs.hideBanner: - tdiv(class="profile-banner"): - renderBanner(user.banner) - - let sticky = if prefs.stickyProfile: " sticky" else: "" - tdiv(class=("profile-tab" & sticky)): - renderUserCard(user, prefs, AccountInfo()) - - tdiv(class="timeline-container"): - renderFollowTabs(user, activeTab) - renderTimelineUsers(displayResults, prefs, path) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index af2f05d..41ef8df 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -4,23 +4,14 @@ import karax/[karaxdsl, vdom, vstyles] import ".."/[types, utils] const smallWebp* = "?name=small&format=webp" -const mediumWebp* = "?name=medium&format=webp" proc getSmallPic*(url: string): string = - if url.len == 0: return result = url if "?" notin url and not url.endsWith("placeholder.png"): result &= smallWebp result = getPicUrl(result) -proc getMediumPic*(url: string): string = - if url.len == 0: return - result = url - if "?" notin url and not url.endsWith("placeholder.png"): - result &= mediumWebp - result = getPicUrl(result) - -proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode = +proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = var c = "icon-" & icon if class.len > 0: c = &"{c} {class}" buildHtml(tdiv(class="icon-container")): @@ -29,15 +20,13 @@ proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode = else: span(class=c, title=title) - if label.len > 0: - text " " & label + if text.len > 0: + text " " & text template verifiedIcon*(user: User): untyped {.dirty.} = if user.verifiedType != VerifiedType.none: let lower = ($user.verifiedType).toLowerAscii() - buildHtml(tdiv(class=(&"verified-icon {lower}"))): - icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account") - icon "ok", class="verified-icon-check", title=(&"Verified {lower} account") + icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") else: text "" @@ -51,6 +40,7 @@ proc linkUser*(user: User, class=""): VNode = buildHtml(a(href=href, class=class, title=nameText)): text nameText if isName: + verifiedIcon(user) if user.protected: text " " icon "lock", title="Protected account" @@ -74,20 +64,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(label(class="pref-group checkbox-container", title=pref)): + buildHtml(label(class="pref-group checkbox-container")): text label input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)): + buildHtml(tdiv(class=("pref-group pref-input " & class))): if label.len > 0: label(`for`=pref): text label input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) proc genSelect*(pref, label, state: string; options: seq[string]): VNode = - buildHtml(tdiv(class="pref-group pref-input", title=pref)): + buildHtml(tdiv(class="pref-group pref-input")): label(`for`=pref): text label select(name=pref): for opt in options: @@ -99,16 +89,9 @@ proc genDate*(pref, state: string): VNode = input(name=pref, `type`="date", value=state) icon "calendar" -proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode = - let p = placeholder - buildHtml(tdiv(class=("pref-group pref-input " & class))): - if label.len > 0: - label(`for`=pref): text label - input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") - -proc genImg*(url: string; class=""; alt=""): VNode = +proc genImg*(url: string; class=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt=alt, loading="lazy") + img(src=getPicUrl(url), class=class, alt="", loading="lazy") proc getTabClass*(query: Query; tab: QueryKind): string = if query.kind == tab: "tab-item active" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 4738705..729e65b 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,140 +1,31 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#import strutils, sequtils, xmltree, strformat, options, unicode +#import strutils, xmltree, strformat, options, unicode #import ../types, ../utils, ../formatters, ../prefs -## Snowflake ID cutoff for RSS GUID format transition -## Corresponds to approximately December 14, 2025 UTC -#const guidCutoff = 2000000000000000000'i64 # #proc getTitle(tweet: Tweet; retweet: string): string = -#var prefix = "" -#if tweet.pinned: prefix = "Pinned: " -#elif retweet.len > 0: prefix = &"RT by @{retweet}: " -#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: " +#if tweet.pinned: result = "Pinned: " +#elif retweet.len > 0: result = &"RT by @{retweet}: " +#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " #end if -#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ") +#var text = stripHtml(tweet.text) ##if unicode.runeLen(text) > 32: ## text = unicode.runeSubStr(text, 0, 32) & "..." ##end if -#text = xmltree.escape(text) -## article tweets' text is just the article link; the title says more -#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0: -# result = prefix & xmltree.escape(tweet.articlePreview.get().title) -# return +#result &= xmltree.escape(text) +#if result.len > 0: return #end if -#if text.len > 0: -# result = prefix & text -# return -#end if -#if tweet.media.len > 0: -# result = prefix -# let firstKind = tweet.media[0].kind -# if tweet.media.anyIt(it.kind != firstKind): -# result &= "Media" -# else: -# case firstKind -# of photoMedia: result &= "Image" -# of videoMedia: result &= "Video" -# of gifMedia: result &= "Gif" -# end case -# end if -#end if -#if result.len == 0 and tweet.card.isSome: -# let card = tweet.card.get() -# if card.kind notin {hidden, unknown} and card.title.len > 0: -# result = prefix & xmltree.escape(card.title) -# end if +#if tweet.photos.len > 0: +# result &= "Image" +#elif tweet.video.isSome: +# result &= "Video" +#elif tweet.gif.isSome: +# result &= "Gif" #end if #end proc # #proc getDescription(desc: string; cfg: Config): string = -Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)} -#end proc -# -#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string = -#case media.kind -#of photoMedia: -# let photo = media.photo - -#of videoMedia: -# let video = media.video - -
Video
- -
-#of gifMedia: -# let gif = media.gif -# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}" -# let url = &"{urlPrefix}{getPicUrl(gif.url)}" - -#end case -#end proc -# -#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string = -#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs) -#let title = xmltree.escape(card.title) -
-Link
-#if cardLink.len > 0: - -#end if -#if card.image.len > 0: - -#end if -#if title.len > 0: -# if card.image.len > 0: -
-# end if -${title} -#end if -#if cardLink.len > 0: -
-#end if -#if card.text.len > 0: -

${xmltree.escape(card.text)}

-#end if -#if cardLink.len > 0: -# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink) -${destText} -#elif card.dest.len > 0: -${xmltree.escape(card.dest)} -#end if -#end proc -# -#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string = -#let link = urlPrefix & "/i/article/" & $article.tweetId -
-Article
- -#if article.coverImage.len > 0: - -#end if -#if article.title.len > 0: -# if article.coverImage.len > 0: -
-# end if -${xmltree.escape(article.title)} -#end if -
-#if article.previewText.len > 0: -

${xmltree.escape(article.previewText)}

-#end if -#end proc -# -#proc renderRssPoll(poll: Poll): string = -
-Poll -

-#for i in 0 ..< poll.options.len: -# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0 -# let pct = (&"{perc:.0f}").strip(chars={'.'}) -# let line = pct & "% — " & xmltree.escape(poll.options[i]) -${line}
-#end for -#let votesStr = insertSep($poll.votes, ',') -${votesStr} votes • ${xmltree.escape(poll.status)} -

+Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end proc # #proc getTweetsWithPinned(profile: Profile): seq[Tweets] = @@ -155,46 +46,38 @@ ${line}
#end if #end proc # -#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix) -#if text.len > 0: +#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

-#end if -#if tweet.media.len > 0: -# for media in tweet.media: -${renderRssMedia(media, tweet, urlPrefix)} -# end for -#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}: -${renderRssCard(tweet.card.get(), prefs, urlPrefix)} -#end if -#if tweet.articlePreview.isSome: -${renderRssArticle(tweet.articlePreview.get(), urlPrefix)} -#end if -#if tweet.poll.isSome: -${renderRssPoll(tweet.poll.get())} -#end if -#if tweet.note.len > 0 and not prefs.hideCommunityNotes: -

Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}

-#end if #if tweet.quote.isSome and get(tweet.quote).available: -# let quoteTweet = get(tweet.quote) -# let quoteLink = urlPrefix & getLink(quoteTweet) -
-
-${quoteTweet.user.fullname} (@${quoteTweet.user.username}) -

-${renderRssTweet(quoteTweet, cfg, prefs)} -

- -
+# let quoteLink = getLink(get(tweet.quote)) +

${cfg.hostname}${quoteLink}

+#end if +#if tweet.photos.len > 0: +# for photo in tweet.photos: + +# end for +#elif tweet.video.isSome: + +
Video
+ +
+#elif tweet.gif.isSome: +# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" +# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" + +#elif tweet.card.isSome: +# let card = tweet.card.get() +# if card.image.len > 0: + +# end if #end if #end proc # -#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string = +#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] #for thread in tweets: @@ -208,24 +91,19 @@ ${renderRssTweet(quoteTweet, cfg, prefs)} # if link in links: continue # end if # links.add link -# let useGlobalGuid = tweet.id >= guidCutoff ${getTitle(tweet, retweet)} @${tweet.user.username} - + ${getRfc822Time(tweet)} -#if useGlobalGuid: - ${tweet.id} -#else: ${urlPrefix & link} -#end if ${urlPrefix & link} # end for #end for #end proc # -#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string = +#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = #let urlPrefix = getUrlPrefix(cfg) #result = "" #let handle = (if multi: "" else: "@") & profile.user.username @@ -251,13 +129,13 @@ ${renderRssTweet(quoteTweet, cfg, prefs)} #let tweetsList = getTweetsWithPinned(profile) #if tweetsList.len > 0: -${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)} +${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} #end if #end proc # -#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string = +#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #result = "" @@ -269,12 +147,12 @@ ${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)} ${getDescription(&"{list.name} by @{list.username}", cfg)} en-us 40 -${renderRssTweets(tweets, cfg, prefs)} +${renderRssTweets(tweets, cfg)} #end proc # -#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string = +#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = #let link = &"{getUrlPrefix(cfg)}/search" #let escName = xmltree.escape(name) #result = "" @@ -287,7 +165,7 @@ ${renderRssTweets(tweets, cfg, prefs)} ${getDescription(&"Search \"{escName}\"", cfg)} en-us 40 -${renderRssTweets(tweets, cfg, prefs)} +${renderRssTweets(tweets, cfg)} #end proc diff --git a/src/views/search.nim b/src/views/search.nim index 4d4ed5e..9f7fc95 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -10,12 +10,14 @@ const toggles = { "media": "Media", "videos": "Videos", "news": "News", + "verified": "Verified", "native_video": "Native videos", "replies": "Replies", "links": "Links", "images": "Images", + "safe": "Safe", "quote": "Quotes", - "spaces": "Spaces" + "pro_video": "Pro videos" }.toOrderedTable proc renderSearch*(): VNode = @@ -36,62 +38,29 @@ proc renderProfileTabs*(query: Query; username: string): VNode = a(href=(link & "/with_replies")): text "Tweets & Replies" li(class=query.getTabClass(media)): a(href=(link & "/media")): text "Media" - if query.fromUser.len == 1: - li(class=query.getTabClass(QueryKind.articles)): - a(href=(link & "/articles")): text "Articles" li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" -proc mediaViewUrl(query: Query; view: string): string = - var q = query - q.view = view - "?" & genQueryUrl(q) - -proc renderMediaViewTabs*(query: Query): VNode = - let currentView = if query.view.len > 0: query.view else: "timeline" - func cls(view: string): string = - if currentView == view: "tab-item active" else: "tab-item" - buildHtml(ul(class="tab media-view-tabs")): - li(class=cls("timeline")): - a(href=query.mediaViewUrl("timeline")): text "Timeline" - li(class=cls("grid")): - a(href=query.mediaViewUrl("grid")): text "Grid" - li(class=cls("gallery")): - a(href=query.mediaViewUrl("gallery")): text "Gallery" - proc renderSearchTabs*(query: Query): VNode = var q = query - # the media view mode only applies to the Media tab - q.view = "" buildHtml(ul(class="tab")): - li(class=query.getTabClass(top)): - q.kind = top - a(href=("?" & genQueryUrl(q))): text "Top" li(class=query.getTabClass(tweets)): q.kind = tweets - a(href=("?" & genQueryUrl(q))): text "Latest" - li(class=query.getTabClass(media)): - q.kind = media - q.view = query.view - a(href=("?" & genQueryUrl(q))): text "Media" + a(href=("?" & genQueryUrl(q))): text "Tweets" li(class=query.getTabClass(users)): q.kind = users - q.view = "" a(href=("?" & genQueryUrl(q))): text "Users" - li(class=query.getTabClass(lists)): - q.kind = lists - a(href=("?" & genQueryUrl(q))): text "Lists" proc isPanelOpen(q: Query): bool = q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or - @[q.minLikes, q.until, q.since].anyIt(it.len > 0)) + @[q.near, q.until, q.since].anyIt(it.len > 0)) proc renderSearchPanel*(query: Query): VNode = let user = query.fromUser.join(",") let action = if user.len > 0: &"/{user}/search" else: "/search" buildHtml(form(`method`="get", action=action, class="search-field", autocomplete="off")): - hiddenField("f", $query.kind) + hiddenField("f", "tweets") genInput("q", "", query.text, "Enter search...", class="pref-inline") button(`type`="submit"): icon "search" @@ -116,58 +85,36 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "-" genDate("until", query.until) tdiv: - span(class="search-title"): text "Minimum likes" - genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false) + span(class="search-title"): text "Near" + genInput("near", "", query.near, "Location...", autofocus=false) proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query - let containerClass = - if query.fromUser.len == 0 and query.kind == QueryKind.media and - query.view == "gallery": "timeline-container media-only" - else: "timeline-container" - buildHtml(tdiv(class=containerClass)): + buildHtml(tdiv(class="timeline-container")): if query.fromUser.len > 1: tdiv(class="timeline-header"): text query.fromUser.join(" | ") if query.fromUser.len > 0: - if query.kind != QueryKind.media or query.view != "gallery": - renderProfileTabs(query, query.fromUser.join(",")) - if query.kind == QueryKind.media and query.fromUser.len == 1: - renderMediaViewTabs(query) + renderProfileTabs(query, query.fromUser.join(",")) - if query.fromUser.len == 0 or query.kind == QueryKind.tweets: + if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"): renderSearchPanel(query) if query.fromUser.len == 0: renderSearchTabs(query) - if query.kind == QueryKind.media: - renderMediaViewTabs(query) renderTimelineTweets(results, prefs, path, pinned) -proc renderSearchForm(kind, placeholder, value: string): VNode = - buildHtml(form(`method`="get", action="/search", - class="search-field", autocomplete="off")): - hiddenField("f", kind) - genInput("q", "", value, placeholder, class="pref-inline") - button(`type`="submit"): icon "search" - proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header"): - renderSearchForm("users", "Enter username...", results.query.text) + form(`method`="get", action="/search", class="search-field", autocomplete="off"): + hiddenField("f", "users") + genInput("q", "", results.query.text, "Enter username...", class="pref-inline") + button(`type`="submit"): icon "search" renderSearchTabs(results.query) renderTimelineUsers(results, prefs) - -proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs; - path: string): VNode = - buildHtml(tdiv(class="timeline-container")): - tdiv(class="timeline-header"): - renderSearchForm("lists", "Enter search...", results.query.text) - - renderSearchTabs(results.query) - renderTimelineLists(results, prefs, path) diff --git a/src/views/space.nim b/src/views/space.nim deleted file mode 100644 index a5cac7b..0000000 --- a/src/views/space.nim +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, times -import karax/[karaxdsl, vdom] - -import renderutils -import ".."/[types, utils, formatters] - -proc renderParticipant(p: SpaceParticipant; role: string): VNode = - buildHtml(tdiv(class="space-participant")): - a(href=("/" & p.username)): - genImg(p.avatarUrl.replace("_normal", "_bigger")) - tdiv(class="participant-info"): - tdiv(class="participant-name"): - strong: text p.displayName - if p.isVerified: - tdiv(class="verified-icon blue"): - icon "circle", class="verified-icon-circle", title="Verified account" - icon "ok", class="verified-icon-check", title="Verified account" - if role.len > 0: - span(class="host-badge"): text role - span(class="participant-username"): text "@" & p.username - -proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode = - let - isLive = sp.state == "RUNNING" - source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"): - getVidUrl(sp.m3u8Url) else: sp.m3u8Url - stateText = - if isLive: "LIVE" - elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY") - elif sp.state.len > 0: sp.state - else: "Ended" - durationMs = - if sp.startTime.year > 1 and sp.endTime.year > 1: - int((sp.endTime - sp.startTime).inMilliseconds) - else: 0 - duration = if durationMs > 0: getDuration(durationMs) else: "" - totalListeners = - if sp.totalReplayWatched > 0: sp.totalReplayWatched - else: sp.totalLiveListeners - - buildHtml(tdiv(class="space-page")): - tdiv(class="space-panel"): - tdiv(class="space-player"): - if sp.m3u8Url.len > 0 and prefs.hlsPlayback: - audio(data-url=source, data-autoload="false") - verbatim "
" - tdiv(class="overlay-circle"): span(class="overlay-triangle") - if isLive: - tdiv(class="space-live"): text "LIVE" - elif duration.len > 0: - tdiv(class="overlay-duration"): text duration - verbatim "
" - elif sp.m3u8Url.len > 0: - tdiv(class="video-overlay"): - buttonReferer "/enablehls", "Enable hls playback", path - if isLive: - tdiv(class="space-live"): text "LIVE" - elif duration.len > 0: - tdiv(class="overlay-duration"): text duration - elif sp.availableForReplay: - tdiv(class="video-overlay"): - p: text "Audio stream unavailable" - else: - tdiv(class="video-overlay"): - p: text "Replay is not available" - - tdiv(class="space-info"): - tdiv(class="space-header"): - h2(class="space-title"): text sp.title - tdiv(class="space-meta"): - if totalListeners > 0: - span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners" - if isLive: - span(class="space-live"): text stateText - else: - span(class="space-state"): text stateText - - if sp.admins.len > 0 or sp.speakers.len > 0: - tdiv(class="space-participants"): - for admin in sp.admins: - let role = if admin.username == sp.creator.username: "Host" - else: "Co-host" - renderParticipant(admin, role) - for speaker in sp.speakers: - renderParticipant(speaker, "") diff --git a/src/views/status.nim b/src/views/status.nim index b16c211..71c2c67 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -1,5 +1,4 @@ # SPDX-License-Identifier: AGPL-3.0-only -import sequtils import karax/[karaxdsl, vdom] import ".."/[types, formatters] @@ -29,46 +28,16 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = if thread.hasMore: renderMoreReplies(thread) -proc renderReplySort(sort: RankingMode): VNode = - buildHtml(tdiv(class="reply-sort")): - span(class="reply-sort-label"): text "Sort replies:" - for mode in RankingMode: - let - cls = if mode == sort: "reply-sort-option active" - else: "reply-sort-option" - label = case mode - of Relevance: "Relevant" - of Recency: "Recent" - of Likes: "Liked" - a(class=cls, href=("?sort=" & $mode & "#r")): - text label - -proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; - tweet: Tweet = nil; sort = Relevance): VNode = +proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="replies", id="r")): - var hasReplies = false - var replyCount = 0 for thread in replies.content: - if thread.content.len == 0 or thread.related: continue - hasReplies = true - replyCount += thread.content.len + if thread.content.len == 0: continue renderReplyThread(thread, prefs, path) - if hasReplies and replies.bottom.len > 0: - if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: - let extra = if sort == Relevance: "" else: "sort=" & $sort & "&" - renderMore(Query(), replies.bottom, focus="#r", extra=extra) + if replies.bottom.len > 0: + renderMore(Query(), replies.bottom, focus="#r") -proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode = - buildHtml(tdiv(class="related-tweets")): - tdiv(class="related-header"): - text "Related tweets" - for thread in replies.content: - if thread.content.len == 0 or not thread.related: continue - renderReplyThread(thread, prefs, path) - -proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; - sort = Relevance): VNode = +proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = let hasAfter = conv.after.content.len > 0 let threadId = conv.tweet.threadId buildHtml(tdiv(class="conversation")): @@ -101,25 +70,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string; if not conv.replies.beginning: renderNewer(Query(), getLink(conv.tweet), focus="#r") if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: - renderReplySort(sort) - renderReplies(conv.replies, prefs, path, conv.tweet, sort) - - if not prefs.hideRelated: - if conv.replies.content.anyIt(it.related and it.content.len > 0): - renderRelated(conv.replies, prefs, path) + renderReplies(conv.replies, prefs, path) renderToTop(focus="#m") - -proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = - buildHtml(tdiv(class="edit-history")): - tdiv(class="latest-edit"): - tdiv(class="edit-history-header"): - text "Latest post" - renderTweet(edits.latest, prefs, path) - - tdiv(class="previous-edits"): - tdiv(class="edit-history-header"): - text "Version history" - for tweet in edits.history: - tdiv(class="tweet-edit"): - renderTweet(tweet, prefs, path) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 2890dd1..abeb6d3 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -5,38 +5,12 @@ import karax/[karaxdsl, vdom] import ".."/[types, query, formatters] import tweet, renderutils -proc timelineViewClass(query: Query): string = - if query.kind != QueryKind.media: - return "timeline" - - case query.view - of "grid": "timeline media-grid-view" - of "gallery": "timeline media-gallery-view" - else: "timeline" - proc getQuery(query: Query): string = if query.kind != posts: result = genQueryUrl(query) if result.len > 0: result &= "&" -proc getSearchMaxId(results: Timeline; path: string): string = - if results.query.kind != tweets or results.content.len == 0 or - results.query.until.len == 0: - return - - let lastThread = results.content[^1] - if lastThread.len == 0 or lastThread[^1].id == 0: - return - - # 2000000 is the minimum decrement to guarantee no result overlap - var maxId = lastThread[^1].id - 2_000_000'i64 - if maxId <= 0: - maxId = lastThread[^1].id - 1 - - if maxId > 0: - return "maxid:" & $maxId - proc renderToTop*(focus="#"): VNode = buildHtml(tdiv(class="top-ref")): icon "down", href=focus @@ -50,9 +24,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode = a(href=(p & url)): text "Load newest" -proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode = +proc renderMore*(query: Query; cursor: string; focus=""): VNode = buildHtml(tdiv(class="show-more")): - a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): + a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): text "Load more" proc renderNoMore(): VNode = @@ -65,7 +39,7 @@ proc renderNoneFound(): VNode = h2(class="timeline-none"): text "No items found" -proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode = +proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="thread-line")): let sortedThread = thread.sortedByIt(it.id) for i, tweet in sortedThread: @@ -79,10 +53,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): V let show = i == thread.high and sortedThread[0].id != tweet.threadId let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" renderTweet(tweet, prefs, path, class=(header & "thread"), - index=i, last=(i == thread.high), bigThumb=bigThumb) + index=i, last=(i == thread.high), showThread=show) proc renderUser(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="timeline-item", data-username=user.username)): + buildHtml(tdiv(class="timeline-item")): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): tdiv(class="tweet-header"): @@ -92,7 +66,6 @@ proc renderUser(user: User; prefs: Prefs): VNode = tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(user, class="fullname") - verifiedIcon(user) linkUser(user, class="username") tdiv(class="tweet-content media-body", dir="auto"): @@ -114,106 +87,15 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = else: renderNoMore() -proc mentionUsername(word: string): string = - # "@user" -> "user" for well-formed mentions, "" otherwise - if word.len > 1 and word[0] == '@' and - word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}): - word[1 .. ^1] - else: "" - -proc mentionedUser(s: string): string = - # last @mention in strings like "65 followers including @user" - let words = s.split(' ') - for i in countdown(words.high, 0): - result = mentionUsername(words[i]) - if result.len > 0: return - -proc renderMentionedText(s: string): VNode = - # linkify @mentions in plain API strings like "65 followers including @user" - let words = s.split(' ') - buildHtml(span): - for i in 0 ..< words.len: - if i > 0: text " " - let username = mentionUsername(words[i]) - if username.len > 0: - a(href=("/" & username)): text words[i] - else: - text words[i] - -proc renderListCard(r: ListSearchResult): VNode = - let listUrl = "/i/lists/" & r.list.id - buildHtml(tdiv(class="timeline-item list-result")): - a(class="tweet-link", href=listUrl) - a(class="list-result-banner", href=listUrl): - if r.list.banner.len > 0: - genImg(r.list.banner) - tdiv(class="list-result-body"): - tdiv(class="list-result-title fullname-and-username"): - a(class="list-name fullname", href=listUrl): text r.list.name - span(class="list-members"): - text &"· {insertSep($r.list.members, ',')} members" - tdiv(class="list-result-context"): - if r.followersContext.len > 0: - # the first facepile belongs to the "including @user" account - let mentioned = mentionedUser(r.followersContext) - for i in 0 ..< r.facepiles.len: - if i == 0 and mentioned.len > 0: - a(class="facepile-link", href=("/" & mentioned)): - genImg(r.facepiles[i], class="list-facepile") - else: - genImg(r.facepiles[i], class="list-facepile") - renderMentionedText(r.followersContext) - else: - if r.owner.username.len > 0: - a(class="facepile-link", href=("/" & r.owner.username)): - genImg(r.owner.getUserPic("_mini"), class="list-facepile") - else: - genImg(r.owner.getUserPic("_mini"), class="list-facepile") - linkUser(r.owner, class="fullname") - linkUser(r.owner, class="username") - if r.list.description.len > 0: - tdiv(class="list-result-description"): - text r.list.description - -proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs; - path=""): VNode = - buildHtml(tdiv(class="timeline")): - if not results.beginning: - renderNewer(results.query, path) - - if results.content.len > 0: - for list in results.content: - renderListCard(list) - if results.bottom.len > 0: - renderMore(results.query, results.bottom) - renderToTop() - elif results.beginning: - renderNoneFound() - else: - renderNoMore() - -proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] = - var retweets: seq[int64] - for thread in threads: - if thread.len == 1: - let tweet = thread[0] - let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 - if retweetId in retweets or tweet.id in retweets or - tweet.pinned and prefs.hidePins: - continue - if retweetId != 0 and tweet.retweet.isSome: - retweets &= retweetId - result.add(thread) - proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = - buildHtml(tdiv(class=results.query.timelineViewClass)): + buildHtml(tdiv(class="timeline")): if not results.beginning: renderNewer(results.query, parseUri(path).path) if not prefs.hidePins and pinned.isSome: let tweet = get pinned - renderTweet(tweet, prefs, path) + renderTweet(tweet, prefs, path, showThread=tweet.hasThread) if results.content.len == 0: if not results.beginning: @@ -221,24 +103,26 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; else: renderNoneFound() else: - let filtered = filterThreads(results.content, prefs) + var retweets: seq[int64] - if results.query.view == "gallery": - let bigThumb = prefs.gallerySize == "Large" - let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry" - tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii): - for thread in filtered: - if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb) - else: renderThread(thread, prefs, path, bigThumb) - else: - for thread in filtered: - if thread.len == 1: - renderTweet(thread[0], prefs, path) - else: renderThread(thread, prefs, path) + for thread in results.content: + if thread.len == 1: + let + tweet = thread[0] + retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 - var cursor = getSearchMaxId(results, path) - if cursor.len > 0: - renderMore(results.query, cursor) - elif results.bottom.len > 0: + if retweetId in retweets or tweet.id in retweets or + tweet.pinned and prefs.hidePins: + continue + + var hasThread = tweet.hasThread + if retweetId != 0 and tweet.retweet.isSome: + retweets &= retweetId + hasThread = get(tweet.retweet).hasThread + renderTweet(tweet, prefs, path, showThread=hasThread) + else: + renderThread(thread, prefs, path) + + if results.bottom.len > 0: renderMore(results.query, results.bottom) renderToTop() diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 8971ab8..8ff8cb1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -5,39 +5,21 @@ from jester import Request import renderutils import ".."/[types, utils, formatters] +import general const doctype = "\n" -proc renderMiniAvatar*(user: User; prefs: Prefs): VNode = +proc renderMiniAvatar(user: User; prefs: Prefs): VNode = genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini")) -proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode = - let url = "/i/article/" & $preview.tweetId - buildHtml(tdiv(class="article-card card large")): - a(class="card-container", href=url): - if preview.coverImage.len > 0: - tdiv(class="card-image-container"): - tdiv(class="card-image"): - genImg(preview.coverImage) - span(class="article-card-badge"): text "Article" - tdiv(class="card-content-container"): - tdiv(class="card-content"): - h2(class="card-title"): text preview.title - if preview.previewText.len > 0: - p(class="card-description"): text preview.previewText - -proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs; - path = ""): VNode = +proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = buildHtml(tdiv): if pinned: - let pinnedLabel = - if "/i/communities/" in path: "Pinned by Community mods" - else: "Pinned Tweet" tdiv(class="pinned"): - span: icon("pin", pinnedLabel) + span: icon "pin", "Pinned Tweet" elif retweet.len > 0: tdiv(class="retweet-header"): - span: icon("retweet", retweet & " retweeted") + span: icon "retweet", retweet & " retweeted" tdiv(class="tweet-header"): a(class="tweet-avatar", href=("/" & tweet.user.username)): @@ -49,28 +31,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs; tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): linkUser(tweet.user, class="fullname") - verifiedIcon(tweet.user) linkUser(tweet.user, class="username") span(class="tweet-date"): a(href=getLink(tweet), title=tweet.getTime): text tweet.getShortTime -proc renderAltText(altText: string): VNode = - buildHtml(p(class="alt-text")): - text "ALT " & altText +proc renderAlbum(tweet: Tweet): VNode = + let + groups = if tweet.photos.len < 3: @[tweet.photos] + else: tweet.photos.distribute(2) -proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode = - buildHtml(tdiv(class="attachment")): - let - named = "name=" in photo.url - thumb = if named: photo.url - elif bigThumb: photo.url & mediumWebp - else: photo.url & smallWebp - a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): - genImg(thumb, alt=photo.altText) - if photo.altText.len > 0: - renderAltText(photo.altText) + buildHtml(tdiv(class="attachments")): + for i, photos in groups: + let margin = if i > 0: ".25em" else: "" + tdiv(class="gallery-row", style={marginTop: margin}): + for photo in photos: + tdiv(class="attachment image"): + let + named = "name=" in photo + small = if named: photo else: photo & smallWebp + a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): + genImg(small) proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = case playbackType @@ -80,11 +62,11 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc hasMp4Url(video: Video): bool = video.variants.anyIt(it.contentType == mp4) -proc renderVideoDisabled(playbackType: VideoType; path=""): VNode = +proc renderVideoDisabled(playbackType: VideoType; path: string): VNode = buildHtml(tdiv(class="video-overlay")): case playbackType of mp4: - buttonReferer "/enablemp4", "Enable mp4 playback", path + p: text "mp4 playback disabled in preferences" of m3u8, vmap: buttonReferer "/enablehls", "Enable hls playback", path @@ -96,109 +78,51 @@ proc renderVideoUnavailable(video: Video): VNode = else: p: text "This media is unavailable" -proc getVideoDownloadUrl(videoData: Video): string = - let mp4Vars = videoData.variants.filterIt(it.contentType == mp4) - if mp4Vars.len == 0: return "" - let best = mp4Vars.sortedByIt(it.bitrate)[^1].url - if best.startsWith("http"): getVidUrl(best) else: best - -proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode = +proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = let - playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4 - else: videoData.playbackType - thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb) - - buildHtml(tdiv(class="attachment")): - if not videoData.available: - img(src=thumb, loading="lazy") - renderVideoUnavailable(videoData) - elif not prefs.isPlaybackEnabled(playbackType): - img(src=thumb, loading="lazy") - renderVideoDisabled(playbackType, path) - else: - let - vars = videoData.variants.filterIt(it.contentType == playbackType) - vidUrl = vars.sortedByIt(it.resolution)[^1].url - source = if prefs.proxyVideos and vidUrl.startsWith("http"): - getVidUrl(vidUrl) else: vidUrl - case playbackType - of mp4: - video(poster=thumb, controls="", muted=prefs.muteVideos): - source(src=source, `type`="video/mp4") - of m3u8, vmap: - video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) - verbatim "
" - tdiv(class="overlay-circle"): span(class="overlay-triangle") - if videoData.durationMs > 0: - tdiv(class="overlay-duration"): text getDuration(videoData) - verbatim "
" - if videoData.available: - let dlUrl = getVideoDownloadUrl(videoData) - if dlUrl.len > 0: - a(class="video-download", href=dlUrl, download="", - title="Download video"): icon "download-alt" - -proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = - let hasCardContent = video.description.len > 0 or video.title.len > 0 + container = if video.description.len == 0 and video.title.len == 0: "" + else: " card-container" + playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4 + else: video.playbackType buildHtml(tdiv(class="attachments card")): - tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))): - renderVideoAttachment(video, prefs, path, bigThumb) - if hasCardContent: + tdiv(class="gallery-video" & container): + tdiv(class="attachment video-container"): + let thumb = getSmallPic(video.thumb) + if not video.available: + img(src=thumb, loading="lazy") + renderVideoUnavailable(video) + elif not prefs.isPlaybackEnabled(playbackType): + img(src=thumb, loading="lazy") + renderVideoDisabled(playbackType, path) + else: + let + vars = video.variants.filterIt(it.contentType == playbackType) + vidUrl = vars.sortedByIt(it.resolution)[^1].url + source = if prefs.proxyVideos: getVidUrl(vidUrl) + else: vidUrl + case playbackType + of mp4: + video(poster=thumb, controls="", muted=prefs.muteVideos): + source(src=source, `type`="video/mp4") + of m3u8, vmap: + video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) + verbatim "
" + tdiv(class="overlay-circle"): span(class="overlay-triangle") + verbatim "
" + if container.len > 0: tdiv(class="card-content"): h2(class="card-title"): text video.title if video.description.len > 0: p(class="card-description"): text video.description -proc renderGifAttachment(gif: Gif; prefs: Prefs; path=""): VNode = - let thumb = getSmallPic(gif.thumb) - - buildHtml(tdiv(class="attachment")): - if not prefs.mp4Playback: - img(src=thumb, loading="lazy") - renderVideoDisabled(mp4, path) - elif prefs.autoplayGifs: - video(class="gif", poster=thumb, autoplay="", muted="", loop=""): - source(src=getPicUrl(gif.url), `type`="video/mp4") - else: - video(class="gif", poster=thumb, controls="", muted="", loop=""): - source(src=getPicUrl(gif.url), `type`="video/mp4") - if gif.altText.len > 0: - renderAltText(gif.altText) - -proc renderGif(gif: Gif; prefs: Prefs; path=""): VNode = +proc renderGif(gif: Gif; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments media-gif")): - renderGifAttachment(gif, prefs, path) - -proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode = - if media.len == 0: - return nil - - if media.len == 1: - let item = media[0] - if item.kind == videoMedia: - return renderVideo(item.video, prefs, path, bigThumb) - if item.kind == gifMedia: - return renderGif(item.gif, prefs, path) - - let - groups = if media.len < 3: @[media] - else: media.distribute(2) - - buildHtml(tdiv(class="attachments")): - for i, mediaGroup in groups: - let margin = if i > 0: ".25em" else: "" - let rowClass = "gallery-row" & - (if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row") - tdiv(class=rowClass, style={marginTop: margin}): - for mediaItem in mediaGroup: - case mediaItem.kind - of photoMedia: - renderPhotoAttachment(mediaItem.photo, bigThumb) - of videoMedia: - renderVideoAttachment(mediaItem.video, prefs, path, bigThumb) - of gifMedia: - renderGifAttachment(mediaItem.gif, prefs, path) + tdiv(class="gallery-gif", style={maxHeight: "unset"}): + tdiv(class="attachment"): + video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs, + controls="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") proc renderPoll(poll: Poll): VNode = buildHtml(tdiv(class="poll")): @@ -254,7 +178,7 @@ func formatStat(stat: int): string = if stat > 0: insertSep($stat, ',') else: "" -proc renderStats*(stats: TweetStats): VNode = +proc renderStats(stats: TweetStats): VNode = buildHtml(tdiv(class="tweet-stats")): span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) @@ -268,9 +192,8 @@ proc renderReply(tweet: Tweet): VNode = if i > 0: text " " a(href=("/" & u)): text "@" & u -proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode = - let href = if link.len > 0: link else: "/" & user.username - buildHtml(a(class="attribution", href=href)): +proc renderAttribution(user: User; prefs: Prefs): VNode = + buildHtml(a(class="attribution", href=("/" & user.username))): renderMiniAvatar(user, prefs) strong: text user.fullname verifiedIcon(user) @@ -284,28 +207,19 @@ proc renderMediaTags(tags: seq[User]): VNode = if i < tags.high: text ", " -proc renderLatestPost(username: string; id: int64): VNode = - buildHtml(tdiv(class="latest-post-version")): - text "There's a new version of this post. " - a(href=getLink(id, username)): - text "See the latest post" - -proc renderCommunityNote(note: string; prefs: Prefs): VNode = - buildHtml(tdiv(class="community-note")): - tdiv(class="community-note-header"): - icon "group" - span: text "Community note" - tdiv(class="community-note-text", dir="auto"): - verbatim replaceUrls(note, prefs) - proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="quote-media-container")): - renderMedia(quote.media, prefs, path) + if quote.photos.len > 0: + renderAlbum(quote) + elif quote.video.isSome: + renderVideo(quote.video.get(), prefs, path) + elif quote.gif.isSome: + renderGif(quote.gif.get(), prefs) proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): - a(class="unavailable-quote", href=getLink(quote, focus=false)): + tdiv(class="unavailable-quote"): if quote.tombstone.len > 0: text quote.tombstone elif quote.text.len > 0: @@ -320,7 +234,6 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="fullname-and-username"): renderMiniAvatar(quote.user, prefs) linkUser(quote.user, class="fullname") - verifiedIcon(quote.user) linkUser(quote.user, class="username") span(class="tweet-date"): @@ -334,31 +247,12 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-text", dir="auto"): verbatim replaceUrls(quote.text, prefs) - if quote.media.len > 0: - renderQuoteMedia(quote, prefs, path) - - if quote.articlePreview.isSome: - renderArticleCard(quote.articlePreview.get(), prefs) - - if quote.note.len > 0 and not prefs.hideCommunityNotes: - renderCommunityNote(quote.note, prefs) - if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" - if quote.history.len > 0 and quote.id != max(quote.history): - tdiv(class="quote-latest"): - text "There's a new version of this post" - -proc renderDisclosures*(tweet: Tweet): VNode = - buildHtml(tdiv(class="disclosures")): - if tweet.isAI: - span(data-disclosure="ai"): - icon "attention-circled", "Made with AI" - if tweet.isAd: - span(data-disclosure="ad"): - icon "attention-circled", "Paid partnership (ad)" + if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: + renderQuoteMedia(quote, prefs, path) proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() @@ -372,15 +266,14 @@ proc renderLocation*(tweet: Tweet): string = return $node proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; - last=false; mainTweet=false; afterTweet=false; - bigThumb=false): VNode = + last=false; showThread=false; mainTweet=false; afterTweet=false): VNode = var divClass = class if index == -1 or last: divClass = "thread-last " & class if not tweet.available: - return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)): - a(class="unavailable-box", href=getLink(tweet)): + return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): + tdiv(class="unavailable-box"): if tweet.tombstone.len > 0: text tweet.tombstone elif tweet.text.len > 0: @@ -401,15 +294,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; tweet = tweet.retweet.get retweet = fullTweet.user.fullname - buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)): + buildHtml(tdiv(class=("timeline-item " & divClass))): if not mainTweet: a(class="tweet-link", href=getLink(tweet)) tdiv(class="tweet-body"): - renderHeader(tweet, retweet, pinned, prefs, path) + renderHeader(tweet, retweet, pinned, prefs) if not afterTweet and index == 0 and tweet.reply.len > 0 and - (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned): + (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): renderReply(tweet) var tweetClass = "tweet-content media-body" @@ -420,16 +313,17 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet) if tweet.attribution.isSome: - renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink) + renderAttribution(tweet.attribution.get(), prefs) if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) - if tweet.articlePreview.isSome: - renderArticleCard(tweet.articlePreview.get(), prefs) - - if tweet.media.len > 0: - renderMedia(tweet.media, prefs, path, bigThumb) + if tweet.photos.len > 0: + renderAlbum(tweet) + elif tweet.video.isSome: + renderVideo(tweet.video.get(), prefs, path) + elif tweet.gif.isSome: + renderGif(tweet.gif.get(), prefs) if tweet.poll.isSome: renderPoll(tweet.poll.get()) @@ -437,29 +331,25 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) - if tweet.note.len > 0 and not prefs.hideCommunityNotes: - renderCommunityNote(tweet.note, prefs) - - if tweet.isAI or tweet.isAd: - renderDisclosures(tweet) - - let - hasEdits = tweet.history.len > 1 - isLatest = hasEdits and tweet.id == max(tweet.history) - if mainTweet: - p(class="tweet-published"): - if hasEdits and isLatest: - a(href=(getLink(tweet, focus=false) & "/history")): - text &"Last edited {getTime(tweet)}" - else: - text &"{getTime(tweet)}" - - if hasEdits and not isLatest: - renderLatestPost(tweet.user.username, max(tweet.history)) + p(class="tweet-published"): text &"{getTime(tweet)}" if tweet.mediaTags.len > 0: renderMediaTags(tweet.mediaTags) if not prefs.hideTweetStats: renderStats(tweet.stats) + + if showThread: + a(class="show-thread", href=("/i/status/" & $tweet.threadId)): + text "Show this thread" + +proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = + let node = buildHtml(html(lang="en")): + renderHead(prefs, cfg, req) + + body: + tdiv(class="tweet-embed"): + renderTweet(tweet, prefs, path, mainTweet=true) + + result = doctype & $node diff --git a/tests/base.py b/tests/base.py index 841094d..010dfbb 100644 --- a/tests/base.py +++ b/tests/base.py @@ -54,18 +54,6 @@ class Timeline(object): none = '.timeline-none' protected = '.timeline-protected' photo_rail = '.photo-rail-grid' - media_view_tabs = '.media-view-tabs' - media_view_timeline = '.media-view-tabs a[href*="view=timeline"]' - media_view_grid = '.media-view-tabs a[href*="view=grid"]' - media_view_gallery = '.media-view-tabs a[href*="view=gallery"]' - media_view_active = '.media-view-tabs .tab-item.active a' - grid_view = '.timeline.media-grid-view' - gallery_view = '.timeline.media-gallery-view' - - -class Search(object): - tab_item = '.tab .tab-item' - tab_active = '.tab .tab-item.active a' class Conversation(object): @@ -76,8 +64,6 @@ class Conversation(object): thread = '.reply' tweet = '.timeline-item' tweet_text = '.tweet-content' - reply_sort = '.reply-sort' - reply_sort_active = '.reply-sort-option.active' class Poll(object): @@ -93,7 +79,7 @@ class Media(object): row = '.gallery-row' image = '.still-image' video = '.gallery-video' - gif = '.media-gif' + gif = '.gallery-gif' class BaseTestCase(BaseCase): diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 3d87c74..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,4 +0,0 @@ -from seleniumbase.config import settings - -settings.SKIP_JS_WAITS = True -settings.WAIT_FOR_RSC_ON_PAGE_LOADS = False diff --git a/tests/poetry.lock b/tests/poetry.lock deleted file mode 100644 index d13bfe0..0000000 --- a/tests/poetry.lock +++ /dev/null @@ -1,1716 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "async-generator" -version = "1.10" -description = "Async generators and context managers for Python 3.5+" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, - {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, -] - -[[package]] -name = "attrs" -version = "25.4.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.7.0" -groups = ["main"] -files = [ - {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, - {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, -] - -[package.dependencies] -soupsieve = ">=1.6.1" -typing-extensions = ">=4.0.0" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "behave" -version = "1.2.6" -description = "behave is behaviour-driven development, Python style" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"}, - {file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"}, -] - -[package.dependencies] -parse = ">=1.8.2" -parse-type = ">=0.4.2" -six = ">=1.11" - -[package.extras] -develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"] -docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"] - -[[package]] -name = "certifi" -version = "2026.1.4" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "os_name == \"nt\" and implementation_name != \"pypy\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "cssselect" -version = "1.4.0" -description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8"}, - {file = "cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "execnet" -version = "2.1.2" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, - {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "fasteners" -version = "0.20" -description = "A python package that provides useful locks" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7"}, - {file = "fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8"}, -] - -[[package]] -name = "filelock" -version = "3.24.3" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, - {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, -] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "idna" -version = "3.11" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mouseinfo" -version = "0.1.3" -description = "An application to display XY position and RGB color information for the pixel currently under the mouse. Works on Python 2 and 3." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7"}, -] - -[package.dependencies] -pyperclip = "*" -python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} - -[[package]] -name = "mycdp" -version = "1.3.2" -description = "Autogenerated CDP utilities for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "mycdp-1.3.2-py3-none-any.whl", hash = "sha256:d097b12a494223b89a666c87915d8acd48d08b92770ff9f5955ab764676790a0"}, - {file = "mycdp-1.3.2.tar.gz", hash = "sha256:945c405eb35d9759bd24c3676b4633124fac222ac132f735e9d2d812b49f1b3d"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, - {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -description = "Capture the outcome of Python function calls." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, - {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, -] - -[package.dependencies] -attrs = ">=19.2.0" - -[[package]] -name = "packaging" -version = "26.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, -] - -[[package]] -name = "parameterized" -version = "0.9.0" -description = "Parameterized testing with any Python test framework" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, - {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, -] - -[package.extras] -dev = ["jinja2"] - -[[package]] -name = "parse" -version = "1.21.1" -description = "parse() is the opposite of format()" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47"}, - {file = "parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3"}, -] - -[[package]] -name = "parse-type" -version = "0.6.6" -description = "Simplifies to build parse types based on the parse module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,>=2.7" -groups = ["main"] -files = [ - {file = "parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c"}, - {file = "parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2"}, -] - -[package.dependencies] -parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""} -six = ">=1.15" - -[package.extras] -develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0)", "ruff ; python_version >= \"3.7\"", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version <= \"3.6\"", "virtualenv (>=20.0.0) ; python_version > \"3.6\"", "wheel"] -docs = ["Sphinx (>=1.6)", "sphinx_bootstrap_theme (>=0.6.0)"] -testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] - -[[package]] -name = "pdbp" -version = "1.8.2" -description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8"}, - {file = "pdbp-1.8.2.tar.gz", hash = "sha256:367c25c17555d3ac1f024b9ad494ff50e6e20f6494a84741487f3e6596d88f94"}, -] - -[package.dependencies] -colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""} -pygments = ">=2.19.2" -tabcompleter = ">=1.4.0" - -[[package]] -name = "pip" -version = "26.0.1" -description = "The PyPA recommended tool for installing Python packages." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b"}, - {file = "pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8"}, -] - -[[package]] -name = "platformdirs" -version = "4.9.2" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, - {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pyautogui" -version = "0.9.54" -description = "PyAutoGUI lets Python control the mouse and keyboard, and other GUI automation tasks. For Windows, macOS, and Linux, on Python 3 and 2." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2"}, -] - -[package.dependencies] -mouseinfo = "*" -pygetwindow = ">=0.0.5" -pymsgbox = "*" -pyscreeze = ">=0.1.21" -python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""} -pytweening = ">=1.0.4" - -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - -[[package]] -name = "pygetwindow" -version = "0.0.9" -description = "A simple, cross-platform module for obtaining GUI information on application's windows." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688"}, -] - -[package.dependencies] -pyrect = "*" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pymsgbox" -version = "2.0.1" -description = "A simple, cross-platform, pure Python module for JavaScript-like message boxes." -optional = false -python-versions = ">=3.4" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b"}, - {file = "pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d"}, -] - -[[package]] -name = "pynose" -version = "1.5.5" -description = "pynose fixes nose to extend unittest and make testing easier" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "pynose-1.5.5-py3-none-any.whl", hash = "sha256:673751d53fcfc79b1e48c14f36c7a24779ad43676eeb85736934de6a2b3d8ec8"}, - {file = "pynose-1.5.5.tar.gz", hash = "sha256:81da4e26473f98dd37497248eef4352d3221d1d56edf874a00c6bdda6daf7f49"}, -] - -[[package]] -name = "pyotp" -version = "2.9.0" -description = "Python One Time Password Library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, - {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, -] - -[package.extras] -test = ["coverage", "mypy", "ruff", "wheel"] - -[[package]] -name = "pyperclip" -version = "1.11.0" -description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, - {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, -] - -[[package]] -name = "pyreadline3" -version = "3.5.4" -description = "A python implementation of GNU readline." -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, - {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, -] - -[package.extras] -dev = ["build", "flake8", "mypy", "pytest", "twine"] - -[[package]] -name = "pyrect" -version = "0.2.0" -description = "PyRect is a simple module with a Rect class for Pygame-like rectangular areas." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78"}, -] - -[[package]] -name = "pyscreeze" -version = "1.0.1" -description = "A simple, cross-platform screenshot module for Python 2 and 3." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be"}, -] - -[[package]] -name = "pysocks" -version = "1.7.1" -description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, - {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, - {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, -] - -[[package]] -name = "pytest" -version = "9.0.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-html" -version = "4.0.2" -description = "pytest plugin for generating HTML reports" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pytest_html-4.0.2-py3-none-any.whl", hash = "sha256:907c3e68462df129d3ee96dee58bd63f70216b06421836b22fd3fd57ef314acb"}, - {file = "pytest_html-4.0.2.tar.gz", hash = "sha256:88682b9e8e51392472546a70a2139b27d6bc1834a4afd3e41da33c9d9f91e4a4"}, -] - -[package.dependencies] -jinja2 = ">=3.0.0" -pytest = ">=7.0.0" -pytest-metadata = ">=2.0.0" - -[package.extras] -docs = ["pip-tools (>=6.13.0)"] -test = ["assertpy (>=1.1)", "beautifulsoup4 (>=4.11.1)", "black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-mock (>=3.7.0)", "pytest-rerunfailures (>=11.1.2)", "pytest-xdist (>=2.4.0)", "selenium (>=4.3.0)", "tox (>=3.24.5)"] - -[[package]] -name = "pytest-metadata" -version = "3.1.1" -description = "pytest plugin for test session metadata" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b"}, - {file = "pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[package.extras] -test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] - -[[package]] -name = "pytest-ordering" -version = "0.6" -description = "pytest plugin to run your tests in a specific order" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6"}, - {file = "pytest_ordering-0.6-py2-none-any.whl", hash = "sha256:27fba3fc265f5d0f8597e7557885662c1bdc1969497cd58aff6ed21c3b617de2"}, - {file = "pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6"}, -] - -[package.dependencies] -pytest = "*" - -[[package]] -name = "pytest-rerunfailures" -version = "16.1" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86"}, - {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"}, -] - -[package.dependencies] -packaging = ">=17.1" -pytest = ">=7.4,<8.2.2 || >8.2.2" - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, - {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - -[[package]] -name = "python-xlib" -version = "0.33" -description = "Python X Library" -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32"}, - {file = "python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398"}, -] - -[package.dependencies] -six = ">=1.10.0" - -[[package]] -name = "python3-xlib" -version = "0.15" -description = "Python3 X Library" -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8"}, -] - -[[package]] -name = "pytweening" -version = "1.2.0" -description = "A collection of tweening (aka easing) functions." -optional = false -python-versions = "*" -groups = ["main"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rich" -version = "14.3.2" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["main"] -files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "sbvirtualdisplay" -version = "1.4.0" -description = "A customized pyvirtualdisplay for SeleniumBase." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "sbvirtualdisplay-1.4.0-py3-none-any.whl", hash = "sha256:516de155219aa342c4e090a3c5126cfe6b12416334bcba3255268e44a5e8a206"}, - {file = "sbvirtualdisplay-1.4.0.tar.gz", hash = "sha256:29a365b509cd7bfde4f758603b7b75703909b11cdf4245abc8f828ed35660d9b"}, -] - -[package.extras] -coverage = ["coverage (>=7.6.1) ; python_version < \"3.9\"", "coverage (>=7.6.9) ; python_version >= \"3.9\"", "pytest-cov (>=5.0.0) ; python_version < \"3.9\"", "pytest-cov (>=6.0.0) ; python_version >= \"3.9\""] -flake8 = ["flake8 (==5.0.4) ; python_version < \"3.9\"", "flake8 (==7.1.1) ; python_version >= \"3.9\"", "mccabe (==0.7.0)", "pycodestyle (==2.12.1) ; python_version >= \"3.9\"", "pycodestyle (==2.9.1) ; python_version < \"3.9\"", "pyflakes (==2.5.0) ; python_version < \"3.9\"", "pyflakes (==3.2.0) ; python_version >= \"3.9\""] - -[[package]] -name = "selenium" -version = "4.40.0" -description = "Official Python bindings for Selenium WebDriver" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"}, - {file = "selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c"}, -] - -[package.dependencies] -certifi = ">=2026.1.4" -trio = ">=0.31.0,<1.0" -trio-typing = ">=0.10.0" -trio-websocket = ">=0.12.2,<1.0" -types-certifi = ">=2021.10.8.3" -types-urllib3 = ">=1.26.25.14" -typing_extensions = ">=4.15.0,<5.0" -urllib3 = {version = ">=2.6.3,<3.0", extras = ["socks"]} -websocket-client = ">=1.8.0,<2.0" - -[[package]] -name = "seleniumbase" -version = "4.46.5" -description = "A complete web automation framework for end-to-end testing." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "seleniumbase-4.46.5-py3-none-any.whl", hash = "sha256:d87ca08ed642c2ed5ddb0be74259f570ecdc443e0a99ea0076abde0d013c8586"}, - {file = "seleniumbase-4.46.5.tar.gz", hash = "sha256:a1e22217874da901d361ce7577bf8d4eda990563d3dd2cfada612663380036c6"}, -] - -[package.dependencies] -attrs = ">=25.4.0" -beautifulsoup4 = ">=4.14.3,<4.15.0" -behave = "1.2.6" -certifi = ">=2026.1.4" -chardet = "5.2.0" -charset-normalizer = ">=3.4.4,<4" -colorama = ">=0.4.6" -cssselect = {version = ">=1.4.0,<2", markers = "python_version >= \"3.10\""} -exceptiongroup = ">=1.3.1" -execnet = {version = "2.1.2", markers = "python_version >= \"3.10\""} -fasteners = ">=0.20" -filelock = {version = ">=3.20.3", markers = "python_version >= \"3.10\""} -h11 = "0.16.0" -idna = ">=3.11" -iniconfig = {version = "2.3.0", markers = "python_version >= \"3.10\""} -Jinja2 = ">=3.1.6" -markdown-it-py = {version = "4.0.0", markers = "python_version >= \"3.10\""} -MarkupSafe = ">=3.0.3" -mdurl = "0.1.2" -mycdp = ">=1.3.2" -nest-asyncio = "1.6.0" -outcome = "1.3.0.post0" -packaging = ">=26.0" -parameterized = "0.9.0" -parse = ">=1.21.0" -parse-type = ">=0.6.6" -pdbp = ">=1.8.2" -pip = ">=26.0.1" -platformdirs = {version = ">=4.5.1", markers = "python_version >= \"3.10\""} -pluggy = "1.6.0" -PyAutoGUI = {version = ">=0.9.54", markers = "platform_system == \"Linux\""} -pygments = ">=2.19.2" -pynose = ">=1.5.5" -pyotp = "2.9.0" -pyreadline3 = {version = ">=3.5.4", markers = "platform_system == \"Windows\""} -pytest = {version = "9.0.2", markers = "python_version >= \"3.11\""} -pytest-html = "4.0.2" -pytest-metadata = "3.1.1" -pytest-ordering = "0.6" -pytest-rerunfailures = {version = "16.1", markers = "python_version >= \"3.10\""} -pytest-xdist = "3.8.0" -python-xlib = {version = "0.33", markers = "platform_system == \"Linux\""} -pyyaml = ">=6.0.3" -requests = ">=2.32.5,<2.33.0" -rich = ">=14.3.2,<15" -sbvirtualdisplay = ">=1.4.0" -selenium = {version = "4.40.0", markers = "python_version >= \"3.10\""} -setuptools = {version = ">=82.0.0", markers = "python_version >= \"3.10\""} -six = ">=1.17.0" -sniffio = "1.3.1" -sortedcontainers = "2.4.0" -soupsieve = ">=2.8.3,<2.9.0" -tabcompleter = ">=1.4.0" -trio = {version = ">=0.32.0,<1", markers = "python_version >= \"3.10\""} -trio-websocket = ">=0.12.2,<0.13.0" -typing-extensions = ">=4.15.0" -urllib3 = {version = ">=1.26.20,<3", markers = "python_version >= \"3.10\""} -websocket-client = ">=1.9.0,<1.10.0" -websockets = {version = ">=16.0", markers = "python_version >= \"3.10\""} -wheel = ">=0.46.3" -wsproto = {version = ">=1.3.2,<1.4.0", markers = "python_version >= \"3.10\""} - -[package.extras] -allure = ["allure-behave (>=2.13.5)", "allure-pytest (>=2.13.5)", "allure-python-commons (>=2.13.5)"] -coverage = ["coverage (>=7.10.7) ; python_version < \"3.10\"", "coverage (>=7.13.4) ; python_version >= \"3.10\"", "pytest-cov (>=7.0.0)"] -flake8 = ["flake8 (==7.3.0)", "mccabe (==0.7.0)", "pycodestyle (==2.14.0)", "pyflakes (==3.4.0)"] -ipdb = ["ipdb (==0.13.13)", "ipython (==7.34.0)"] -mss = ["mss (==10.1.0)"] -pdfminer = ["cffi (==2.0.0)", "cryptography (==46.0.5)", "pdfminer.six (==20251107) ; python_version < \"3.10\"", "pdfminer.six (==20260107) ; python_version >= \"3.10\"", "pycparser (==2.23) ; python_version < \"3.10\"", "pycparser (==3.0) ; python_version >= \"3.10\""] -pillow = ["Pillow (>=11.3.0) ; python_version < \"3.10\"", "Pillow (>=12.1.1) ; python_version >= \"3.10\""] -pip-system-certs = ["pip-system-certs (==4.0) ; platform_system == \"Windows\""] -playwright = ["playwright (>=1.58.0)"] -proxy = ["proxy.py (==2.4.3)"] -psutil = ["psutil (>=7.2.2)"] -pyautogui = ["PyAutoGUI (>=0.9.54) ; platform_system != \"Linux\""] -selenium-stealth = ["selenium-stealth (==1.0.6)"] -selenium-wire = ["Brotli (==1.1.0)", "blinker (==1.7.0)", "h2 (==4.1.0)", "hpack (==4.0.0)", "hyperframe (==6.0.1)", "kaitaistruct (==0.10)", "pyOpenSSL (>=24.2.1)", "pyasn1 (==0.6.1)", "pyparsing (>=3.1.4)", "selenium-wire (==5.1.0)", "zstandard (>=0.23.0)"] - -[[package]] -name = "setuptools" -version = "82.0.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"}, - {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, - {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, -] - -[[package]] -name = "soupsieve" -version = "2.8.3" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, - {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, -] - -[[package]] -name = "tabcompleter" -version = "1.4.0" -description = "tabcompleter --- Autocompletion in the Python console." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b"}, - {file = "tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814"}, -] - -[package.dependencies] -pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "trio" -version = "0.33.0" -description = "A friendly Python library for async concurrency and I/O" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"}, - {file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"}, -] - -[package.dependencies] -attrs = ">=23.2.0" -cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} -idna = "*" -outcome = "*" -sniffio = ">=1.3.0" -sortedcontainers = "*" - -[[package]] -name = "trio-typing" -version = "0.10.0" -description = "Static type checking support for Trio and related projects" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3"}, - {file = "trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"}, -] - -[package.dependencies] -async-generator = "*" -importlib-metadata = "*" -mypy-extensions = ">=0.4.2" -packaging = "*" -trio = ">=0.16.0" -typing-extensions = ">=3.7.4" - -[package.extras] -mypy = ["mypy (>=1.0)"] - -[[package]] -name = "trio-websocket" -version = "0.12.2" -description = "WebSocket library for Trio" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, - {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, -] - -[package.dependencies] -outcome = ">=1.2.0" -trio = ">=0.11" -wsproto = ">=0.14" - -[[package]] -name = "types-certifi" -version = "2021.10.8.3" -description = "Typing stubs for certifi" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f"}, - {file = "types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"}, -] - -[[package]] -name = "types-urllib3" -version = "1.26.25.14" -description = "Typing stubs for urllib3" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, - {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.dependencies] -pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "websocket-client" -version = "1.9.0" -description = "WebSocket client for Python with low level API options" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, - {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, -] - -[package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] -optional = ["python-socks", "wsaccel"] -test = ["pytest", "websockets"] - -[[package]] -name = "websockets" -version = "16.0" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, - {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, - {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, - {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, - {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, - {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, - {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, - {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, - {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, - {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, - {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, - {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, - {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, - {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, - {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, - {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, - {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, - {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, - {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, - {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, - {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, - {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, - {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, - {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, - {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, - {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, - {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, - {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, - {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, - {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, - {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, - {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, - {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, - {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, - {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, -] - -[[package]] -name = "wheel" -version = "0.46.3" -description = "Command line tool for manipulating wheel files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, - {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, -] - -[package.dependencies] -packaging = ">=24.0" - -[package.extras] -test = ["pytest (>=6.0.0)", "setuptools (>=77)"] - -[[package]] -name = "wsproto" -version = "1.3.2" -description = "Pure-Python WebSocket protocol implementation" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, - {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, -] - -[package.dependencies] -h11 = ">=0.16.0,<1" - -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.1" -python-versions = "^3.14" -content-hash = "11e7820330aef0a91b8b1b35791aaa335cf1352f21316f2dedaf5439afe474ac" diff --git a/tests/poetry.toml b/tests/poetry.toml deleted file mode 100644 index ab1033b..0000000 --- a/tests/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -in-project = true diff --git a/tests/pyproject.toml b/tests/pyproject.toml deleted file mode 100644 index 1907e60..0000000 --- a/tests/pyproject.toml +++ /dev/null @@ -1,11 +0,0 @@ -[tool.poetry] -name = "nitter-tests" -version = "0.0.0" -package-mode = false - -[tool.poetry.dependencies] -python = "^3.14" -seleniumbase = "4.46.5" - -[tool.pytest.ini_options] -addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5" diff --git a/tests/requirements.txt b/tests/requirements.txt index e47d1cc..56ea4c0 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1 @@ -seleniumbase==4.46.5 +seleniumbase diff --git a/tests/test_about_account.py b/tests/test_about_account.py deleted file mode 100644 index 2239d9d..0000000 --- a/tests/test_about_account.py +++ /dev/null @@ -1,76 +0,0 @@ -from base import BaseTestCase, Profile -from parameterized import parameterized - - -class AboutAccount(object): - header = '.about-account-header' - name = '.about-account-name' - body = '.about-account-body' - row = '.about-account-row' - label = '.about-account-label' - value = '.about-account-value' - - -# (username, expected_labels) -# Each label is checked for presence in the page text -about_data = [ - ['jack', ['Date joined', 'Account based in', 'Connected via']], - ['NASA', ['Date joined']], - ['elonmusk', ['Date joined']], -] - -about_verified = [ - ['jack', 'Verified', 'Since '], -] - -about_affiliate = [ - ['jack', 'An affiliate of', 'Square'], - ['elonmusk', 'An affiliate of', 'X'], -] - - -class AboutAccountTest(BaseTestCase): - @parameterized.expand(about_data) - def test_about_page_has_labels(self, username, expected_labels): - """About page shows expected info labels""" - self.open_nitter(f'{username}/about') - self.assert_element_visible(AboutAccount.header) - self.assert_element_visible(AboutAccount.body) - for label in expected_labels: - self.assert_text(label, AboutAccount.body) - - @parameterized.expand(about_verified) - def test_about_verified(self, username, label, value_prefix): - """About page shows verification info for verified accounts""" - self.open_nitter(f'{username}/about') - self.assert_text(label, AboutAccount.body) - self.assert_text(value_prefix, AboutAccount.body) - - @parameterized.expand(about_affiliate) - def test_about_affiliate(self, username, label, affiliate): - """About page shows affiliate info""" - self.open_nitter(f'{username}/about') - self.assert_text(label, AboutAccount.body) - self.assert_text(f'@{affiliate}', AboutAccount.body) - - def test_about_page_title(self): - """Title contains account name""" - self.open_nitter('jack/about') - self.assert_text('jack', AboutAccount.name) - - def test_about_join_date(self): - """About page always shows join date""" - self.open_nitter('jack/about') - self.assert_text('Date joined', AboutAccount.body) - self.assert_text('March 2006', AboutAccount.body) - - def test_about_invalid_user(self): - """About page for non-existent user shows error""" - self.open_nitter('thisprofiledoesntexist/about') - self.assert_text('User "thisprofiledoesntexist" not found') - - def test_joindate_links_to_about(self): - """Join date on profile page links to about page""" - self.open_nitter('jack') - link = self.find_element(Profile.joinDate + ' a') - self.assertIn('/jack/about', link.get_attribute('href')) diff --git a/tests/test_article.py b/tests/test_article.py deleted file mode 100644 index 287b948..0000000 --- a/tests/test_article.py +++ /dev/null @@ -1,327 +0,0 @@ -from base import BaseTestCase -from parameterized import parameterized - - -class ArticleSelectors: - page = '.article-page' - cover = '.article-cover' - body = '.article-body' - title = '.article-title' - author = '.article-author' - fullname = '.article-author .fullname' - username = '.article-author .username' - date = '.article-author .article-date' - avatar = '.article-author img.avatar' - verified = '.article-author .verified-icon' - media = '.article-media' - caption = '.article-media-caption' - divider = '.article-divider' - - -articles = [ - ['2064166507438059759', - '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】', - 'punk2898', 'Punk'], - - ['2064689664213041529', - 'SpaceX Thesis & Valuation Memorandum', - 'Dialectic_Group', 'Dialectic'], - - ['2064691088636424322', - 'Consciousness and AI: The Problem of Inner Experience', - 'CosmicOrFun', 'Cosmic Orphan'], - - ['2064755789391110154', - 'DeFi Markets Update 2026-06-10', - 'SteakhouseFi', 'Steakhouse Financial'], - - ['2064755231901319527', - 'The machine economy has a killswitch and somebody just pulled it.', - '1914ad', 'Justin Bechler HMP-028'], - - ['2062858677149675788', - 'Yakshinis', - 'CosmicOrFun', 'Cosmic Orphan'], -] - -articles_with_media = [ - ['2064166507438059759', 6], - ['2064689664213041529', 11], - ['2064755789391110154', 5], -] - -articles_with_dividers = [ - ['2064166507438059759', 1], - ['2064689664213041529', 6], -] - - -class ArticleBasicTest(BaseTestCase): - @parameterized.expand(articles) - def test_article_loads(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - self.assert_element_visible(ArticleSelectors.page) - self.assert_element_visible(ArticleSelectors.body) - self.assert_text(title, ArticleSelectors.title) - - @parameterized.expand(articles) - def test_article_author(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - self.assert_element_visible(ArticleSelectors.author) - self.assert_text(f'@{username}', ArticleSelectors.username) - - @parameterized.expand(articles) - def test_article_has_cover(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - self.assert_element_visible(ArticleSelectors.cover) - src = self.get_attribute(ArticleSelectors.cover, 'src') - self.assertIn('/pic/', src) - - @parameterized.expand(articles) - def test_article_has_date(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - date_text = self.get_text(ArticleSelectors.date) - self.assertTrue(len(date_text) > 3) - - @parameterized.expand(articles) - def test_article_author_avatar(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - self.assert_element_visible(ArticleSelectors.avatar) - src = self.get_attribute(ArticleSelectors.avatar, 'src') - self.assertIn('/pic/', src) - self.assertGreater(len(src), len('/pic/')) - - @parameterized.expand(articles) - def test_article_author_verified(self, tweet_id, title, username, fullname): - self.open_nitter(f'i/article/{tweet_id}') - self.assert_element_visible(ArticleSelectors.verified) - - def test_article_author_verified_business(self): - self.open_nitter('i/article/2064755789391110154') - self.assert_element_visible('.article-author .verified-icon.business') - - -class ArticleContentTest(BaseTestCase): - def test_article_has_paragraphs(self): - self.open_nitter('i/article/2064689664213041529') - paragraphs = self.find_elements('.article-body p') - self.assertGreater(len(paragraphs), 10) - - def test_article_has_headers(self): - self.open_nitter('i/article/2064689664213041529') - headers = self.find_elements('.article-body h1, .article-body h2') - self.assertGreater(len(headers), 5) - - def test_article_has_bold_text(self): - self.open_nitter('i/article/2064166507438059759') - bold = self.find_elements('.article-body strong') - self.assertGreater(len(bold), 0) - - def test_article_has_italic_text(self): - self.open_nitter('i/article/2064166507438059759') - italic = self.find_elements('.article-body em') - self.assertGreater(len(italic), 0) - - def test_article_has_blockquotes(self): - self.open_nitter('i/article/2064166507438059759') - self.assert_element_visible('.article-body blockquote') - - def test_article_has_lists(self): - self.open_nitter('i/article/2064166507438059759') - self.assert_element_visible('.article-body ul') - - def test_article_has_emoji_text(self): - self.open_nitter('i/article/2064166507438059759') - body = self.get_text(ArticleSelectors.body) - self.assertTrue(any(ord(c) > 0x1F000 for c in body)) - - def test_article_has_links(self): - self.open_nitter('i/article/2064691088636424322') - links = self.find_elements('.article-body a[href]') - self.assertGreater(len(links), 0) - - def test_article_twitter_links_localized(self): - self.open_nitter('i/article/2064755789391110154') - links = self.find_elements('.article-body a[href^="https://x.com"]') - self.assertEqual(len(links), 0, 'x.com links should be converted to local paths') - - @parameterized.expand(articles_with_media) - def test_article_media_count(self, tweet_id, expected_count): - self.open_nitter(f'i/article/{tweet_id}') - media = self.find_elements(ArticleSelectors.media) - self.assertEqual(len(media), expected_count) - - @parameterized.expand(articles_with_dividers) - def test_article_divider_count(self, tweet_id, expected_count): - self.open_nitter(f'i/article/{tweet_id}') - dividers = self.find_elements(ArticleSelectors.divider) - self.assertEqual(len(dividers), expected_count) - - -class ArticleMediaTest(BaseTestCase): - def test_media_images_proxied(self): - self.open_nitter('i/article/2064689664213041529') - self.assert_element_visible(ArticleSelectors.media) - img = self.find_element(f'{ArticleSelectors.media} img') - src = img.get_attribute('src') - self.assertIn('/pic/', src) - self.assertFalse(src.startswith('https://pbs.twimg.com')) - - def test_cover_image_proxied(self): - self.open_nitter('i/article/2064689664213041529') - self.assert_element_visible(ArticleSelectors.cover) - src = self.get_attribute(ArticleSelectors.cover, 'src') - self.assertIn('/pic/', src) - self.assertFalse(src.startswith('https://pbs.twimg.com')) - - def test_embedded_tweet(self): - self.open_nitter('i/article/2064755789391110154') - self.assert_element_visible('.article-body .timeline-item') - - def test_multiple_embedded_tweets(self): - self.open_nitter('i/article/2064755231901319527') - tweets = self.find_elements('.article-body .timeline-item') - self.assertGreaterEqual(len(tweets), 3) - - def test_media_caption_displayed(self): - self.open_nitter('i/article/2064689664213041529') - self.assert_element_visible(ArticleSelectors.caption) - captions = self.find_elements(ArticleSelectors.caption) - self.assertGreaterEqual(len(captions), 5) - - def test_media_caption_text(self): - self.open_nitter('i/article/2064689664213041529') - self.assert_text_visible('FIGURE 1', ArticleSelectors.caption) - - def test_media_caption_alt_attribute(self): - self.open_nitter('i/article/2064689664213041529') - img = self.find_element(f'{ArticleSelectors.media} img') - alt = img.get_attribute('alt') - self.assertGreater(len(alt), 0) - - def test_no_caption_when_absent(self): - self.open_nitter('i/article/2062858677149675788') - captions = self.find_elements(ArticleSelectors.caption) - self.assertEqual(len(captions), 0) - - -class ArticleMentionTest(BaseTestCase): - def test_mention_linkified(self): - self.open_nitter('i/article/2064755231901319527') - link = self.find_element('.article-body a[href="/ZachXBT"]') - self.assertEqual(link.text, '@ZachXBT') - - def test_multiple_mentions_linkified(self): - self.open_nitter('i/article/2064755231901319527') - links = self.find_elements('.article-body a[href^="/"]') - mention_hrefs = [l.get_attribute('href') for l in links - if l.text.startswith('@')] - usernames = [h.split('/')[-1] for h in mention_hrefs] - self.assertIn('ZachXBT', usernames) - self.assertIn('River', usernames) - - def test_mention_in_different_article(self): - self.open_nitter('i/article/2064689664213041529') - link = self.find_element('.article-body a[href="/FutureJurvetson"]') - self.assertEqual(link.text, '@FutureJurvetson') - - def test_no_spurious_whitespace_in_styled_paragraph(self): - """Styled paragraphs should not have extra whitespace from VNode serialization.""" - self.open_nitter('i/article/2064166507438059759') - source = self.get_page_source() - self.assertNotIn('white-space: pre-wrap', source) - self.assertNotIn('white-space:pre-wrap', source) - - -class ArticleCardTest(BaseTestCase): - @parameterized.expand(articles) - def test_status_page_shows_article_card(self, tweet_id, title, username, fullname): - self.open_nitter(f'{username}/status/{tweet_id}') - self.assert_element_visible('.article-card') - self.assert_text(title, '.article-card .card-title') - - def test_article_card_has_cover_image(self): - self.open_nitter('Dialectic_Group/status/2064689664213041529') - self.assert_element_visible('.article-card .card-image img') - src = self.get_attribute('.article-card .card-image img', 'src') - self.assertIn('/pic/', src) - - def test_article_card_has_badge(self): - self.open_nitter('Dialectic_Group/status/2064689664213041529') - self.assert_element_visible('.article-card-badge') - self.assert_text('Article', '.article-card-badge') - - def test_article_card_has_preview_text(self): - self.open_nitter('CosmicOrFun/status/2064691088636424322') - self.assert_element_visible('.article-card .card-description') - - def test_article_card_links_to_article(self): - self.open_nitter('punk2898/status/2064166507438059759') - href = self.get_attribute('.article-card .card-container', 'href') - self.assertIn('/article/', href) - - def test_article_url_stripped_from_tweet_text(self): - self.open_nitter('punk2898/status/2064166507438059759') - self.assert_element_visible('.article-card') - source = self.get_page_source() - # Main tweet text should not contain article URL - import re - main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)', source, re.DOTALL) - self.assertIsNotNone(main) - self.assertNotIn('/article/', main.group(1)) - - -class ArticleQuotedCardTest(BaseTestCase): - """Article cards inside quoted tweets (1914ad quoting own article).""" - quoted_tweet = '1914ad/status/2064789532071891085' - quoted_article_id = '2063677483548102688' - - def test_quoted_card_visible(self): - self.open_nitter(self.quoted_tweet) - self.assert_element_visible('.quote .article-card') - - def test_quoted_card_has_title(self): - self.open_nitter(self.quoted_tweet) - self.assert_text('David Bailey Already Won', '.quote .article-card .card-title') - - def test_quoted_card_has_badge(self): - self.open_nitter(self.quoted_tweet) - self.assert_element_visible('.quote .article-card-badge') - self.assert_text('Article', '.quote .article-card-badge') - - def test_quoted_card_has_cover_image(self): - self.open_nitter(self.quoted_tweet) - # Scroll to element to trigger lazy loading - self.scroll_to('.quote .article-card .card-image img') - self.assert_element_visible('.quote .article-card .card-image img') - src = self.get_attribute('.quote .article-card .card-image img', 'src') - self.assertIn('/pic/', src) - - def test_quoted_card_has_description(self): - self.open_nitter(self.quoted_tweet) - self.assert_element_visible('.quote .article-card .card-description') - - def test_quoted_card_links_to_article(self): - self.open_nitter(self.quoted_tweet) - href = self.get_attribute('.quote .article-card .card-container', 'href') - self.assertIn(f'/article/{self.quoted_article_id}', href) - - -class ArticleRoutingTest(BaseTestCase): - def test_username_article_route_redirects(self): - self.open_nitter('punk2898/article/2064166507438059759') - self.assert_element_visible(ArticleSelectors.page) - self.assert_text('1s', ArticleSelectors.title) - - def test_status_article_route_redirects(self): - self.open_nitter('punk2898/status/2064166507438059759/article') - self.assert_element_visible(ArticleSelectors.page) - self.assert_text('1s', ArticleSelectors.title) - - def test_invalid_id_returns_404(self): - self.open_nitter('i/article/notanumber') - self.assert_element_not_visible(ArticleSelectors.page) - - def test_nonexistent_article(self): - self.open_nitter('i/article/1') - self.assert_element_visible('.error-panel') diff --git a/tests/test_card.py b/tests/test_card.py index daee099..504c079 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -1,5 +1,3 @@ -import os -import unittest from base import BaseTestCase, Card, Conversation from parameterized import parameterized @@ -15,16 +13,21 @@ card = [ 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 'gist.github.com', True], - ['NASA/status/2061872347477418301', - 'Nancy Grace Roman Space Telescope - NASA Science', - 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.', - 'science.nasa.gov', True] + ['nim_lang/status/1082989146040340480', + 'Nim in 2018: A short recap', + 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', + 'nim-lang.org', True] ] no_thumb = [ + ['FluentAI/status/1116417904831029248', + 'LinkedIn', + 'This link will take you to a page that’s not on LinkedIn', + 'lnkd.in'], + ['Thom_Wolf/status/1122466524860702729', - 'GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model', - 'PyTorch original implementation of Cross-lingual Language Model Pretraining.', + 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in', + '', 'github.com'], ['brent_p/status/1088857328680488961', @@ -39,17 +42,20 @@ no_thumb = [ ] playable = [ - ['NASA/status/2047048645845897398', - 'NASA\'s Artemis II News Conference with Moon Astronauts', - 'Live from NASA\'s Johnson Space Center in Houston', + ['nim_lang/status/1118234460904919042', + 'Nim development blog 2019-03', + 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', + 'youtube.com'], + + ['nim_lang/status/1121090879823986688', + 'Nim - First natively compiled language w/ hot code-reloading at...', + '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...', 'youtube.com'] ] class CardTest(BaseTestCase): @parameterized.expand(card) def test_card(self, tweet, title, description, destination, large): - if os.environ.get('GITHUB_ACTIONS') == 'true' and '2061872347477418301' in tweet: - self.skipTest('Card image unreliable from GitHub datacenter IPs') self.open_nitter(tweet) c = Card(Conversation.main + " ") self.assert_text(title, c.title) @@ -71,7 +77,7 @@ class CardTest(BaseTestCase): if len(description) > 0: self.assert_text(description, c.description) - @parameterized.expand(playable, skip_on_empty=True) + @parameterized.expand(playable) def test_card_playable(self, tweet, title, description, destination): self.open_nitter(tweet) c = Card(Conversation.main + " ") diff --git a/tests/test_community.py b/tests/test_community.py deleted file mode 100644 index b266044..0000000 --- a/tests/test_community.py +++ /dev/null @@ -1,211 +0,0 @@ -from base import BaseTestCase -from parameterized import parameterized - - -COMMUNITY_ID = '1493446837214187523' -COMMUNITY_PATH = f'i/communities/{COMMUNITY_ID}' - - -class CommunityTest(BaseTestCase): - def test_top_page_loads(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.community-header') - self.assert_text('Build in Public', '.community-name') - - def test_banner_visible(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.timeline-banner img') - - def test_member_count(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.community-member-count') - self.assert_text('Members', '.community-member-count') - - def test_description_visible(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.community-description') - - def test_tabs_present(self): - self.open_nitter(COMMUNITY_PATH) - tabs = self.find_elements('.tab a') - labels = [t.text for t in tabs] - self.assertEqual(labels, ['Top', 'Latest', 'Media', 'About']) - - def test_top_tab_active(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.tab .active a[href$="/' + COMMUNITY_ID + '"]') - - def test_top_has_tweets(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.timeline-item .tweet-body') - - def test_top_has_pagination(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.show-more') - self.assert_text('Load more', '.show-more') - - def test_latest_has_tweets(self): - self.open_nitter(f'{COMMUNITY_PATH}/latest') - self.assert_element_visible('.timeline-item .tweet-body') - - def test_latest_tab_active(self): - self.open_nitter(f'{COMMUNITY_PATH}/latest') - self.assert_element_visible('.tab .active a[href$="/latest"]') - - def test_media_has_tweets(self): - self.open_nitter(f'{COMMUNITY_PATH}/media') - self.assert_element_visible('.timeline-item .tweet-body') - - def test_media_tab_active(self): - self.open_nitter(f'{COMMUNITY_PATH}/media') - self.assert_element_visible('.tab .active a[href$="/media"]') - - def test_about_page(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_element_visible('.community-about') - self.assert_text('Community Info', '.community-info h2') - - def test_about_rules(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_element_visible('.community-rules') - self.assert_text('Rules', '.community-rules h2') - rules = self.find_elements('.community-rule') - self.assertGreater(len(rules), 0) - - def test_about_creator(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_text('Created', '.community-info') - link = self.find_element('.community-info-item a') - self.assertTrue(link.text.startswith('@')) - self.assertGreater(len(link.text), 1) - - def test_about_tab_active(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_element_visible('.tab .active a[href$="/about"]') - - def test_about_moderators(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_element_visible('.community-moderators') - self.assert_text('Moderators', '.community-moderators h2') - mods = self.find_elements('.community-moderator') - self.assertGreater(len(mods), 0) - - def test_about_moderators_have_avatars(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - avatars = self.find_elements('.community-mod-avatar') - self.assertGreater(len(avatars), 0) - - def test_about_moderators_link_to_profiles(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - links = self.find_elements('.community-mod-username') - self.assertGreater(len(links), 0) - for link in links: - self.assertTrue(link.text.startswith('@')) - self.assertTrue(link.get_attribute('href').startswith('http')) - - def test_about_see_all_link(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - link = self.find_element('.community-mods-link') - self.assertEqual(link.text, 'See all') - self.assertIn('/moderators', link.get_attribute('href')) - - def test_members_page(self): - self.open_nitter(f'{COMMUNITY_PATH}/members') - self.assert_element_visible('.timeline-item') - users = self.find_elements('.timeline-item .username') - self.assertGreater(len(users), 0) - - def test_members_has_member_tabs(self): - self.open_nitter(f'{COMMUNITY_PATH}/members') - tabs = self.find_elements('.tab a') - labels = [t.text for t in tabs] - self.assertEqual(labels, ['All', 'Moderators']) - - def test_members_all_tab_active(self): - self.open_nitter(f'{COMMUNITY_PATH}/members') - self.assert_element_visible('.tab .active a[href$="/members"]') - - def test_members_count_is_link(self): - self.open_nitter(COMMUNITY_PATH) - link = self.find_element('.community-member-count') - self.assertIn('Members', link.text) - self.assertIn('/members', link.get_attribute('href')) - - def test_moderators_page(self): - self.open_nitter(f'{COMMUNITY_PATH}/moderators') - self.assert_element_visible('.timeline-item') - users = self.find_elements('.timeline-item .username') - self.assertGreater(len(users), 0) - - def test_moderators_tab_active(self): - self.open_nitter(f'{COMMUNITY_PATH}/moderators') - self.assert_element_visible('.tab .active a[href$="/moderators"]') - - def test_moderators_has_member_tabs(self): - self.open_nitter(f'{COMMUNITY_PATH}/moderators') - tabs = self.find_elements('.tab a') - labels = [t.text for t in tabs] - self.assertEqual(labels, ['All', 'Moderators']) - - def test_pinned_tweet_label(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.pinned') - self.assert_text('Pinned by Community mods', '.pinned') - - def test_hashtags_visible(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.community-tags') - tags = self.find_elements('.community-tag') - self.assertGreater(len(tags), 0) - - def test_hashtags_are_links(self): - self.open_nitter(COMMUNITY_PATH) - tags = self.find_elements('.community-tag') - for tag in tags: - href = tag.get_attribute('href') - self.assertIn('/hashtag/', href) - self.assertTrue(tag.text.startswith('#')) - - def test_hashtag_page(self): - self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') - self.assert_element_visible('.timeline-item .tweet-body') - - def test_hashtag_shows_header(self): - self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') - self.assert_element_visible('.community-header') - self.assert_text('Build in Public', '.community-name') - - def test_hashtag_shows_tag_title(self): - self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') - self.assert_element_visible('.community-hashtag-header') - self.assert_text('#buildinpublic', '.community-hashtag-title') - - def test_hashtag_no_main_tabs(self): - self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic') - tabs = self.find_elements('.tab a') - tab_labels = [t.text for t in tabs] - self.assertNotIn('Top', tab_labels) - self.assertNotIn('About', tab_labels) - - def test_category_visible(self): - self.open_nitter(COMMUNITY_PATH) - self.assert_element_visible('.community-category') - - def test_about_join_policy(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_text('Anyone can join', '.community-info') - - def test_about_visibility_note(self): - self.open_nitter(f'{COMMUNITY_PATH}/about') - self.assert_text('publicly visible', '.community-info') - - def test_404_invalid_id(self): - self.open_nitter('i/communities/999') - self.assert_element_visible('.error-panel') - self.assert_text('not found', '.error-panel') - - @parameterized.expand(['', '/latest', '/media', '/about', - '/members', '/moderators']) - def test_page_no_error(self, suffix): - self.open_nitter(f'{COMMUNITY_PATH}{suffix}') - self.assert_element_not_visible('.error-panel') diff --git a/tests/test_embed.py b/tests/test_embed.py deleted file mode 100644 index cc7a28a..0000000 --- a/tests/test_embed.py +++ /dev/null @@ -1,282 +0,0 @@ -import os -import unittest -import requests -from base import BaseTestCase, Media -from parameterized import parameterized - - -class Embed: - container = '.tweet-embed' - footer = '.embed-footer' - tweet_content = '.tweet-content' - tweet_header = '.tweet-header' - fullname = '.fullname' - username = '.username' - avatar = '.avatar' - stats = '.tweet-stats' - quote = '.quote' - error_panel = '.error-panel' - - -class TweetEmbedTest(BaseTestCase): - """Test tweet embed rendering.""" - tweet = 'elonmusk/status/1141367104702038016' - - def test_embed_container_visible(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.container) - - def test_embed_has_footer(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.footer) - self.assert_text_visible('Read more on', Embed.footer) - - def test_embed_has_tweet_content(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.tweet_content) - - def test_embed_has_avatar(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.avatar) - - def test_embed_has_username(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.username) - - def test_embed_has_stats(self): - self.open_nitter(self.tweet + '/embed') - self.assert_element_visible(Embed.stats) - - def test_embed_footer_links_to_tweet(self): - self.open_nitter(self.tweet + '/embed') - href = self.get_attribute(Embed.footer, 'href') - self.assertIn('/elonmusk/status/1141367104702038016', href) - - -class TweetEmbedMediaTest(BaseTestCase): - """Test embed rendering with various media types.""" - - def test_embed_with_image(self): - self.open_nitter('mobile_test/status/519364660823207936/embed') - self.assert_element_visible(Embed.container) - self.scroll_to(Media.container) - self.assert_element_visible(Media.image) - - def test_embed_with_gif(self): - self.open_nitter('elonmusk/status/1141367104702038016/embed') - self.assert_element_visible(Embed.container) - self.scroll_to(Media.container) - self.assert_element_visible(Media.gif) - - @unittest.skipIf(os.environ.get('GITHUB_ACTIONS') == 'true', - 'TweetResultByRestId is Cloudflare-blocked from GitHub datacenter IPs') - def test_embed_with_video(self): - self.open_nitter('d0m96/status/1078373829917974528/embed') - self.assert_element_visible(Embed.container) - self.scroll_to(Media.container) - self.assert_element_visible(Media.video) - - def test_embed_with_gallery(self): - self.open_nitter('mobile_test/status/451108446603980803/embed') - self.assert_element_visible(Embed.container) - self.scroll_to(Media.container) - self.assert_element_visible(Media.row) - - -class TweetEmbedQuoteTest(BaseTestCase): - """Test embed rendering with quoted tweets.""" - - def test_embed_with_quote_shows_quote(self): - self.open_nitter('elonmusk/status/1138827760107790336/embed') - self.assert_element_visible(Embed.container) - self.assert_element_visible(Embed.quote) - - def test_embed_quote_has_content(self): - self.open_nitter('elonmusk/status/1138827760107790336/embed') - quote = self.find_element(Embed.quote) - self.assertIsNotNone(quote.text) - - -class EmbedErrorTest(BaseTestCase): - """Test embed error handling.""" - - def test_nonexistent_tweet_shows_error(self): - self.open_nitter('nobody/status/1/embed') - self.assert_element_visible('.tweet-embed.error-embed') - self.assert_text_visible('not found', Embed.error_panel) - - def test_protected_account_embed_shows_error(self): - self.open_nitter('mobile_test_7/status/1/embed') - self.assert_element_visible('.tweet-embed.error-embed') - - def test_invalid_tweet_id_shows_error(self): - self.open_nitter('jack/status/notanumber/embed') - self.assert_element_visible('.tweet-embed.error-embed') - - -class OEmbedApiTest(BaseTestCase): - """Test oEmbed API endpoint.""" - base_url = 'http://localhost:8080' - tweet_url = 'https://twitter.com/elonmusk/status/1141367104702038016' - - def test_oembed_returns_json(self): - resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.headers['Content-Type'], 'application/json') - - def test_oembed_has_required_fields(self): - resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') - data = resp.json() - self.assertEqual(data['type'], 'rich') - self.assertEqual(data['version'], '1.0') - self.assertIn('html', data) - self.assertIn('author_name', data) - self.assertIn('provider_name', data) - - def test_oembed_html_contains_iframe(self): - resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') - data = resp.json() - self.assertIn(' resolves the numeric id and redirects to the profile (issue #1433)""" - self.open_nitter(f'i/user/{user_id}') - self.assert_true(self.get_current_url().rstrip('/').endswith(f'/{username}')) - self.assert_exact_text(f'@{username}', Profile.username) - - @parameterized.expand(id_redirects) - def test_intent_user_redirect(self, user_id, username): - """/intent/user?user_id= redirects to the profile (issue #1433)""" - self.open_nitter(f'intent/user?user_id={user_id}') - self.assert_true(self.get_current_url().rstrip('/').endswith(f'/{username}')) - self.assert_exact_text(f'@{username}', Profile.username) diff --git a/tests/test_reply_sort.py b/tests/test_reply_sort.py deleted file mode 100644 index 1d89e5d..0000000 --- a/tests/test_reply_sort.py +++ /dev/null @@ -1,37 +0,0 @@ -from parameterized import parameterized - -from base import BaseTestCase, Conversation - -sort_modes = [ - ['jack/status/20', 'Relevant'], - ['jack/status/20?sort=relevance', 'Relevant'], - ['jack/status/20?sort=recency', 'Recent'], - ['jack/status/20?sort=likes', 'Liked'], - ['jack/status/20?sort=garbage', 'Relevant'], - ['jack/status/20?sort=%3Cscript%3E', 'Relevant'], -] - - -class ReplySortTest(BaseTestCase): - @parameterized.expand(sort_modes) - def test_active_mode(self, page, expected_active): - self.open_nitter(page) - self.assert_element_visible(Conversation.reply_sort) - active = self.get_text(Conversation.reply_sort_active) - self.assert_equal(active.strip(), expected_active) - - def test_all_three_options_present(self): - self.open_nitter('jack/status/20') - options = self.find_elements('.reply-sort-option') - labels = [o.text.strip() for o in options] - self.assert_equal(labels, ['Relevant', 'Recent', 'Liked']) - - def test_option_links_carry_sort_param(self): - self.open_nitter('jack/status/20') - for slug in ['Relevance', 'Recency', 'Likes']: - self.assert_element(f'.reply-sort-option[href="?sort={slug}#r"]') - - def test_load_more_preserves_sort(self): - self.open_nitter('jack/status/20?sort=Likes') - href = self.get_attribute('.replies .show-more a', 'href') - self.assert_true('sort=Likes' in href, f'sort missing from: {href}') diff --git a/tests/test_search.py b/tests/test_search.py index 0f5456f..62c4640 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,129 +1,9 @@ +from base import BaseTestCase from parameterized import parameterized -from base import BaseTestCase, Search -# [url, expected active tab label] -active_tabs = [ - ['search?f=tweets&q=nasa', 'Latest'], - ['search?f=top&q=nasa', 'Top'], - ['search?f=media&q=nasa', 'Media'], - ['search?f=users&q=nasa', 'Users'], - ['search?f=lists&q=test', 'Lists'], - # unknown/hostile values fall back to Latest - ['search?f=garbage&q=nasa', 'Latest'], - ['search?f=%3Cscript%3E&q=nasa', 'Latest'], - # x.com URL compat: f=live/user/list (f=media/top match natively) - ['search?f=live&q=nasa', 'Latest'], - ['search?f=user&q=nasa', 'Users'], - ['search?f=list&q=test', 'Lists'], -] - -results_pages = [ - ['search?f=tweets&q=nasa'], - ['search?f=top&q=nasa'], - ['search?f=media&q=nasa'], -] - - -class SearchProductTest(BaseTestCase): - @parameterized.expand(active_tabs) - def test_active_tab(self, page, expected_active): - self.open_nitter(page) - active = self.get_text(Search.tab_active) - self.assert_equal(active.strip(), expected_active) - - def test_all_tabs_present(self): - self.open_nitter('search?f=tweets&q=nasa') - tabs = self.find_elements(Search.tab_item) - labels = [t.text.strip() for t in tabs] - self.assert_equal(labels, ['Top', 'Latest', 'Media', 'Users', 'Lists']) - - @parameterized.expand(results_pages) - def test_results_render(self, page): - self.open_nitter(page) - self.assert_element('.timeline .timeline-item') - - def test_tab_links_carry_kind(self): - self.open_nitter('search?f=tweets&q=nasa') - self.assert_element('.tab-item a[href="?f=top&q=nasa"]') - self.assert_element('.tab-item a[href="?f=media&q=nasa"]') - self.assert_element('.tab-item a[href="?f=tweets&q=nasa"]') - self.assert_element('.tab-item a[href="?f=users&q=nasa"]') - self.assert_element('.tab-item a[href="?f=lists&q=nasa"]') - - def test_show_more_preserves_kind(self): - self.open_nitter('search?f=media&q=nasa') - href = self.get_attribute('.show-more a', 'href') - self.assert_true('f=media' in href, f'f=media missing from: {href}') - - def test_search_form_preserves_kind(self): - self.open_nitter('search?f=top&q=nasa') - self.assert_element_present('.search-field input[name="f"][value="top"]') - - def test_media_operators_compose(self): - self.open_nitter('search?f=media&q=nasa&e-nativeretweets=on') - self.assert_element('.timeline .timeline-item') - - @parameterized.expand([['DAAC'], ['AB'], ['maxid:'], ['maxid:abc']]) - def test_garbage_cursor_no_crash(self, cursor): - # short/invalid cursors must render the page, not a 500 error - self.open_nitter(f'search?f=media&q=nasa&cursor={cursor}') - self.assert_element(Search.tab_active) - - def test_no_results(self): - self.open_nitter('search?f=media&q=xkqzjwv_no_results_2026') - self.assert_text('No items found', '.timeline-none') - - def test_list_results_render(self): - self.open_nitter('search?f=lists&q=test') - self.assert_element('.timeline-item.list-result') - self.assert_element('.list-result .list-name') - self.assert_element('.list-result .list-members') - - def test_list_card_links_to_list(self): - self.open_nitter('search?f=lists&q=test') - href = self.get_attribute('.list-result .list-name', 'href') - self.assert_true('/i/lists/' in href, f'unexpected list link: {href}') - - def test_list_row_clickable(self): - self.open_nitter('search?f=lists&q=test') - href = self.get_attribute('.list-result a.tweet-link', 'href') - self.assert_true('/i/lists/' in href, f'unexpected row link: {href}') - - def test_list_avatar_links_to_user(self): - self.open_nitter('search?f=lists&q=test') - # the avatar link in a row must point at the user named in that row - row = '.list-result:has(a.facepile-link)' - self.assert_element(f'{row} a.facepile-link > img') - href = self.get_attribute(f'{row} a.facepile-link', 'href') - ctx = self.get_text(f'{row} .list-result-context') - mentioned = ctx.split('@')[-1].strip() - self.assert_true(href.endswith('/' + mentioned), - f'avatar link {href} does not match @{mentioned}') - - def test_list_pagination_preserves_kind(self): - self.open_nitter('search?f=lists&q=test') - href = self.get_attribute('.show-more a', 'href') - self.assert_true('f=lists' in href, f'f=lists missing from: {href}') - - def test_list_garbage_cursor_no_crash(self): - self.open_nitter('search?f=lists&q=test&cursor=DAAC') - self.assert_element(Search.tab_active) - - def test_media_view_tabs_present(self): - self.open_nitter('search?f=media&q=nasa') - tabs = self.find_elements('.media-view-tabs .tab-item') - labels = [t.text.strip() for t in tabs] - self.assert_equal(labels, ['Timeline', 'Grid', 'Gallery']) - - def test_media_view_grid(self): - self.open_nitter('search?f=media&q=nasa&view=grid') - self.assert_element('.timeline.media-grid-view') - - def test_media_view_gallery(self): - self.open_nitter('search?f=media&q=nasa&view=gallery') - self.assert_element('.timeline.media-gallery-view .gallery-masonry') - - def test_media_view_tabs_only_on_media(self): - self.open_nitter('search?f=tweets&q=nasa') - self.assert_element_not_present('.media-view-tabs') +#class SearchTest(BaseTestCase): + #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) + #def test_username_search(self, username): + #self.search_username(username) + #self.assert_text(f'{username}') diff --git a/tests/test_security.py b/tests/test_security.py deleted file mode 100644 index 04ba680..0000000 --- a/tests/test_security.py +++ /dev/null @@ -1,60 +0,0 @@ -import subprocess -from parameterized import parameterized - -BASE_URL = 'http://localhost:8080' - - -def curl_status(url): - """Get HTTP status code using curl to avoid URL normalization by Python libs.""" - result = subprocess.run( - ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url], - capture_output=True, text=True, timeout=30 - ) - return int(result.stdout) - - -class TestMalformedPaths: - """Test that malformed paths don't crash the server. - - URLs like //foo are parsed as having 'foo' as the authority (host), - resulting in an empty path. Empty paths previously crashed jester's - static file handler. Now they return 400. - - URLs like //foo/bar are parsed as authority='foo', path='/bar', - so they route normally (not empty path). - """ - - @parameterized.expand([ - # These parse to empty paths -> 400 - ('//lefty_rae', 400), - ('//test', 400), - ('//anyuser', 400), - ]) - def test_empty_path_returns_400(self, path, expected_status): - """URLs that parse to empty paths should return 400, not crash.""" - status = curl_status(f'{BASE_URL}{path}') - assert status == expected_status, \ - f'Expected {expected_status} for {path}, got {status}' - - @parameterized.expand([ - ('/jack', 200), - ('/about', 200), - ('/', 200), - ]) - def test_normal_paths_work(self, path, expected_status): - """Normal paths should still work.""" - status = curl_status(f'{BASE_URL}{path}') - assert status == expected_status, \ - f'Expected {expected_status} for {path}, got {status}' - - def test_server_survives_malformed_requests(self): - """Server should handle malformed requests without crashing.""" - # These all parse to empty paths - malformed_paths = ['//a', '//b', '//c', '//user', '//test'] - for path in malformed_paths: - status = curl_status(f'{BASE_URL}{path}') - assert status == 400, f'Expected 400 for {path}, got {status}' - - # Verify server is still responding after malformed requests - status = curl_status(f'{BASE_URL}/') - assert status == 200, 'Server should still be alive' diff --git a/tests/test_space.py b/tests/test_space.py deleted file mode 100644 index d2ba672..0000000 --- a/tests/test_space.py +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -"""Integration tests for Twitter Spaces support.""" -import pytest -from seleniumbase import BaseCase - - -class TestSpacePage(BaseCase): - """Tests for /i/spaces/@id route.""" - - SPACE_ID = "1mxPaaRAwYjKN" - SPACE_URL = f"http://localhost:8080/i/spaces/{SPACE_ID}" - - def test_space_page_loads(self): - """Space page should load with title.""" - self.open(self.SPACE_URL) - self.assert_element(".space-page") - self.assert_element(".space-panel") - self.assert_text_visible("INTEL WILL MOON NEXT WEEK", ".space-title") - - def test_space_host_info(self): - """Space should display host in participants.""" - self.open(self.SPACE_URL) - self.assert_element(".space-participants") - self.assert_text_visible("bubble boi") - self.assert_element(".host-badge") - - def test_space_metadata(self): - """Space should display listener count and state.""" - self.open(self.SPACE_URL) - self.assert_element(".space-meta") - # Should show listener count (number format) - meta_text = self.get_text(".space-meta") - assert any(c.isdigit() for c in meta_text), "Should show listener count" - # Should show ended state - assert "Ended" in meta_text or "Jun" in meta_text, "Should show ended state" - - def test_space_participants(self): - """Space should display host and speakers.""" - self.open(self.SPACE_URL) - self.assert_element(".space-participants") - # Host should have badge - self.assert_element(".host-badge") - self.assert_text_visible("Host", ".host-badge") - # Should show speakers - self.assert_text_visible("CANTELOPEPEEL") - self.assert_text_visible("Based Burner Account") - self.assert_text_visible("anon invests") - - def test_space_participant_avatars(self): - """Participant avatars should load correctly.""" - self.open(self.SPACE_URL) - # Check avatars in participants section - avatars = self.find_elements(".space-participant img") - assert len(avatars) >= 4, "Should have at least 4 participant avatars" - for avatar in avatars: - src = avatar.get_attribute("src") - # Should NOT be double-encoded - assert "%2Fpic%2F" not in src, f"Avatar URL double-encoded: {src}" - # Should have valid path - assert "/pic/" in src, f"Avatar URL missing /pic/: {src}" - - def test_space_player_hls_disabled(self): - """Without HLS, should show enable button with video-overlay style.""" - self.open(self.SPACE_URL) - self.assert_element(".space-player") - self.assert_element(".video-overlay") - # Should show duration in overlay-duration - self.assert_element(".overlay-duration") - # Should have enable button - source = self.get_page_source() - assert "Enable hls playback" in source - - def test_space_player_hls_enabled(self): - """With HLS enabled, should have audio element in DOM.""" - self.open(self.SPACE_URL) - # Set HLS preference via cookie - self.add_cookie({"name": "hlsPlayback", "value": "on"}) - self.refresh() - # Check page source for audio element (hidden until play clicked) - source = self.get_page_source() - assert '